diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 096902d..5408dbc 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -32,6 +32,8 @@ jobs: # action fetch a remote version mapping that fails the job on any # network blip. Keep in sync with nixpkgs' golangci-lint in flake.nix. version: v2.12.2 + - name: Comments + run: go run ./tools/commentlint . test: name: Test (${{ matrix.os }}) diff --git a/AGENTS.md b/AGENTS.md index ef93d28..87ef61a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,7 @@ This checkout is the tool's own source, not a fleet home itself - there is no `s ## Rules -- Zero comments by default. Only add one when the WHY is non-obvious: a hidden constraint, a subtle invariant, a workaround for a specific bug. Never restate code, narrate what, add banners, or docstring the obvious. +- Comments obey two rules `make lint` enforces through `tools/commentlint`: a comment may not open with the identifier it documents, and a comment block may not exceed three lines. CONTRIBUTING.md's "Comments" section owns the bar for writing one at all, the exemptions, and the reasoning. - Command output goes through `internal/axi` as TOON and every failure through `cmd/root.go`'s error document; `hand watch`'s event stream is the one exception, and SPECS.md's "Output shape" section owns the contract. - Harness/herdr syntax, exit-code enforcement, watch's stdout/errOut split, and first-run prompt handling are commented at point of use (`internal/herdr`, `internal/harness`, `cmd/root.go`, `cmd/precondition.go`, `internal/watcher`, `cmd/teardown.go`, `cmd/prdetect.go`, `cmd/merge.go`, `cmd/launch.go`); SPECS.md's "Exit codes" and each command's spec section own the authoritative tables. - `herdr`, `treehouse` and `gh` are faked once, in `internal/faketool`, for every suite; a test declares the fleet it wants rather than writing sh. `internal/faketool/FIDELITY.md` records what the real tools do and `tests/contract` (`make contract`) rechecks that record against them. Extend the shared fake, never hand-write another; SPECS.md's "Testing strategy" owns the rule behind it. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fb7b01d..7c0afa4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -25,6 +25,29 @@ Without Nix, install those yourself. Commits use conventional commits: feat:, fix:, chore:, etc. release-please handles versioning and changelogs from these. +## Comments + +The default is no comment. +Add one only for a why the code cannot show: a hidden constraint, a subtle invariant, a workaround for a specific bug. +Restating code, narrating what, banners, and doc comments on the obvious are noise no linter can catch, so they stay a reviewer's call. + +Two rules bound the comments that clear that bar, enforced by `make lint` rather than by a reviewer reading a diff: + +1. A comment may not open with the identifier it documents. +2. A comment block may not exceed three lines. + +Consecutive `//` lines are one block, and neither a bare `//` line inside a run nor a blank line above a doc comment breaks it. +Both are ways of writing six lines of prose in front of one declaration while satisfying a three-line rule, and the blank-line form also drops the first half out of godoc. +Rule 1 applies wherever Go's doc convention does not: unexported declarations, everything in `_test.go`, and comments inside function bodies. +An exported declaration's doc comment is required by convention to open with its name, so it is exempt from rule 1, but not from rule 2. +Exempt from both rules: the package doc comment, directives (`//go:build`, `//go:generate`, `//nolint`, `// #nosec`), and files carrying the generated-code header. + +Rule 2 will occasionally be wrong, because a genuinely subtle invariant sometimes needs a fourth line. +That is accepted: a rule that is right most of the time and mechanically enforced binds harder than one that is right always and enforced never. +Prose that outgrows three lines belongs in SPECS.md, which is where it is read. + +`go run ./tools/commentlint .` runs the check alone and prints one `file:line:column` per violation. + ## Reporting issues Open a GitHub issue with repro steps, OS, arch, and hand --version. diff --git a/Makefile b/Makefile index 5dc5261..5be1626 100644 --- a/Makefile +++ b/Makefile @@ -16,6 +16,7 @@ lint: @output=$$(gofmt -l .); if [ -n "$$output" ]; then echo "Files not formatted:"; echo "$$output"; exit 1; fi go vet ./... golangci-lint run + go run ./tools/commentlint . e2e: go test -tags=e2e -timeout=10m ./tests/e2e/... diff --git a/SPECS.md b/SPECS.md index b2244b9..4ddea42 100644 --- a/SPECS.md +++ b/SPECS.md @@ -178,6 +178,9 @@ secondhand/ # maintainer's in-repo fleet home = repo checkout axi.go # fields, row blocks, --fields selection, truncation hints, help[] lines sessionhook/ # ambient context for a supervising session (see "Ambient context") sessionhook.go # install, repoint and report the SessionStart hook entry + tools/ + commentlint/ # the comment check `make lint` and CI run (see "Repo scaffolding") + main.go # walk the tree, report one file:line:column per violation go.mod go.sum AGENTS.md # agent instructions (~25 lines of rules) @@ -2496,6 +2499,7 @@ An existing fleet home has live state on disk, and the import has to meet it wit A value the invocation did not supply is not a usage error: the same malformed value read from a `config/` default is a general error (code `1`). - `3`: precondition failed, meaning the command refuses because the world is not in the state it requires: unlanded work, red CI, a missing or unmerged PR, a missing brief or report, a task, project or hold that does not exist, an id carrying an open hold (`hand spawn`), a task in the wrong kind or state (already merged, not a completed scout, already claimed by another command), a project name or worktree already taken, a project still referenced by active tasks, a PR that conflicts with one already recorded for a task or belongs to neither the task's project's repo nor its declared upstream (`hand pr`), a PR that `gh pr view` can't confirm exists (`hand pr`), a task branch whose PRs do not resolve to a single usable winner (`hand teardown`), a `no-mistakes`-mode project whose gate is not initialized (`hand spawn`, `hand promote` - see "Gate preflight"), a fleet home that already has a watcher attached (`hand watch`, remedied by `--takeover` - see "One watcher per fleet home"). Two more apply to every command, since each one resolves a fleet home before it does anything: the working directory has no fleet home at or above it and `HAND_HOME` is unset, or `HAND_HOME` is set to a directory that is not a fleet home. The second refuses rather than falling back to the walk up, because a silent fallback is how an operator dispatches into the wrong fleet. + These are signalled to `cmd` as sentinel errors (`cmd/precondition.go`), each carrying only the trailing phrase and wrapped by its caller as ` "" `, so one condition renders as one string wherever it surfaces. - `4`: no event delivered, only from `hand watch --until-event`: its `--timeout` elapsed, or it was signaled, without a transition. This includes the timeout elapsing anywhere in arming, the herdr reachability probe as well as the per-task probe sweep - the window is over either way, and no one task is at fault. Distinct from `0` because there the exit *is* the event delivery, and from `1` because the watcher itself did not fail (see "Delivering an event to a supervisory agent"). - `5`: arm-time probe failure, only from `hand watch --until-event`: one named task's herdr pane answered its pre-wait probe with a failure, named on stderr. Distinct from `4` because a specific worker is at fault and can be acted on, and from `0` because nothing was delivered (see "Delivering an event to a supervisory agent"). - `6`: send undelivered, only from `hand send`: the composer stayed busy for the whole `--wait` bound, so the message never reached the pane. Distinct from `1`, which for `hand send` means the send can never succeed (no such herdr pane, herdr itself erroring) - `6` means the opposite, a transient state a caller can retry, most simply with a longer `--wait`. Not `4` or `5`: those are reserved to `hand watch --until-event`. @@ -2768,6 +2772,9 @@ Files tracked in the source repo (not generated by `hand init`): **`.golangci.yaml`:** the tracked file is authoritative - it keeps golangci-lint's default linter set and only sets `run.build-tags: [e2e]`, without which the `//go:build e2e` package in `tests/e2e` is invisible to the linter. +**`tools/commentlint/`:** the tracked source is authoritative - a `go run ./tools/commentlint .` target that `make lint` and the CI workflow both invoke over the whole tree, exiting 1 with one `file:line:column` per violation. +CONTRIBUTING.md's "Comments" section owns the two rules it checks, their exemptions, and why they are the only two that are machine-checkable. + **`.gitignore`:** the tracked file is authoritative - the built binary, the `hand init` runtime directories, Go and Nix build output, worktree tooling files, and editor/OS cruft. **`flake.nix`:** the tracked file is authoritative - a `packages.default` derivation building the `hand` binary and a `devShells.default` carrying the Go toolchain. diff --git a/cmd/fields.go b/cmd/fields.go index cceb973..47a55f4 100644 --- a/cmd/fields.go +++ b/cmd/fields.go @@ -7,8 +7,8 @@ import ( "github.com/atqamz/secondhand/internal/axi" ) -// pickFields resolves --fields against cols, defaulting to def. An unknown name -// is a usage error, not a silently narrower schema header. +// Resolves --fields against cols, defaulting to def. An unknown name is a usage error, not a silently +// narrower schema header. func pickFields[T any](cols []axi.Column[T], fields, def []string) ([]axi.Column[T], error) { want := fields if len(want) == 0 { @@ -21,9 +21,8 @@ func pickFields[T any](cols []axi.Column[T], fields, def []string) ([]axi.Column return out, nil } -// rejectFieldsWithJSON keeps --fields honest: it narrows the TOON schema -// header, and silently ignoring it next to --json would hand a caller the full -// object it asked to narrow. +// Keeps --fields honest: it narrows the TOON schema header, and silently ignoring it next to --json +// would hand a caller the full object it asked to narrow. func rejectFieldsWithJSON(fields []string, asJSON bool) error { if len(fields) > 0 && asJSON { return &ExitError{Err: fmt.Errorf("--fields applies to the default TOON output, not --json"), Code: 2} diff --git a/cmd/fleethome_test.go b/cmd/fleethome_test.go index e1fc309..1949fa7 100644 --- a/cmd/fleethome_test.go +++ b/cmd/fleethome_test.go @@ -6,19 +6,16 @@ import ( "testing" ) -// mkFleetDirs lays down the markers home.Resolve requires to recognize dir as -// a fleet home, for fixtures that chdir into a bare temp directory without -// going through hand init. It also neutralizes an ambient HAND_HOME, which -// would otherwise outrank the fixture and point the command under test at the -// developer's real fleet. -// -// Both marker sets are written, not just state/hand.db: tests that fault the -// store by turning state/hand.db into a directory would otherwise stop being -// homes at all, and the command would fail on home resolution before ever -// reaching the fault under test. +// Lays down the markers home.Resolve requires to recognize dir as a fleet home, for fixtures that chdir +// into a bare temp directory without going through hand init. func mkFleetDirs(t *testing.T, dir string) { t.Helper() + // Neutralizes an ambient HAND_HOME, which would otherwise outrank the fixture and point the command + // under test at the developer's real fleet. t.Setenv("HAND_HOME", "") + // Both marker sets are written, not just state/hand.db: tests that fault the store by turning + // state/hand.db into a directory would otherwise stop being homes at all, and the command would fail on + // home resolution before ever reaching the fault under test. for _, sub := range []string{"data", "state"} { if err := os.MkdirAll(filepath.Join(dir, sub), 0o755); err != nil { t.Fatal(err) diff --git a/cmd/gatepreflight_test.go b/cmd/gatepreflight_test.go index c602725..e3f78c1 100644 --- a/cmd/gatepreflight_test.go +++ b/cmd/gatepreflight_test.go @@ -12,24 +12,23 @@ import ( "github.com/atqamz/secondhand/internal/state" ) -// fakeNoMistakesPath writes a fake no-mistakes binary that answers every subcommand with the same -// text, mirroring the real binary's observed behavior documented in internal/project.GateStatus: -// `no-mistakes status` exits 0 whether or not the repo is initialized, so the outcome is read from -// stdout text rather than the exit code. Returns a PATH with the fake binary's directory prepended -// ahead of the real PATH: the script's own "cat" still needs to resolve, and the fake must win the -// lookup over any real no-mistakes already on this machine. +// Writes a fake no-mistakes binary that answers every subcommand with the same text, mirroring the +// real behavior internal/project.GateStatus documents: `no-mistakes status` exits 0 whether or not the +// repo is initialized, so the outcome is read from stdout text rather than the exit code. func fakeNoMistakesPath(t *testing.T, stdout string) string { + // Prepended ahead of the real PATH, not replacing it: the script's own "cat" still needs to resolve, + // and the fake must win the lookup over any real no-mistakes already on this machine. return fakeNoMistakesPathExit(t, stdout, 0) } -// fakeNoMistakesPathExit is fakeNoMistakesPath with an explicit exit code, for the invocations the -// real binary refuses non-zero: `no-mistakes runs` exits 1 on both "repo not initialized" and "not -// in a git repository", where `no-mistakes status` exits 0 printing the same text. GateRunPRs reads -// the refusal from the text either way, so the fake reproduces the exit code rather than flattening -// every refusal to 0. +// fakeNoMistakesPath with an explicit exit code, for the invocations the real binary refuses non-zero: +// `no-mistakes runs` exits 1 on both "repo not initialized" and "not in a git repository", where +// `no-mistakes status` exits 0 printing the same text. func fakeNoMistakesPathExit(t *testing.T, stdout string, code int) string { t.Helper() bin := t.TempDir() + // GateRunPRs reads the refusal from the text either way, so the fake reproduces the exit code rather + // than flattening every refusal to 0. script := fmt.Sprintf("#!/bin/sh\ncat <<'EOF'\n%s\nEOF\nexit %d\n", stdout, code) if err := os.WriteFile(filepath.Join(bin, "no-mistakes"), []byte(script), 0o755); err != nil { t.Fatal(err) @@ -37,9 +36,9 @@ func fakeNoMistakesPathExit(t *testing.T, stdout string, code int) string { return bin + string(os.PathListSeparator) + os.Getenv("PATH") } -// fakeHerdrPaneDone fakes only "pane get", answering the pane as done (not busy), enough for -// promote's precondition check to pass through to gatePreflight without needing the rest of a -// clean promote's herdr calls, which gatePreflight's refusal preempts. +// Fakes only "pane get", answering the pane as done (not busy), enough for promote's precondition +// check to pass through to gatePreflight without needing the rest of a clean promote's herdr calls, +// which gatePreflight's refusal preempts. const fakeHerdrPaneDone = `#!/bin/sh cmd="$1 $2" case "$cmd" in @@ -53,10 +52,9 @@ case "$cmd" in esac ` -// setupSpawnHomeGate registers a no-mistakes-mode project and nothing else: gatePreflight fires -// in spawn before state.Claim, the brief check, or any herdr/treehouse call, so none of those need -// to exist for these tests. noMistakesPath becomes PATH verbatim, letting each test control -// exactly whether a fake no-mistakes binary is reachable. +// Registers a no-mistakes-mode project and nothing else: gatePreflight fires in spawn before +// state.Claim, the brief check, or any herdr/treehouse call, so none of those need to exist here. +// noMistakesPath becomes PATH verbatim, letting each test control whether the fake is reachable. func setupSpawnHomeGate(t *testing.T, noMistakesPath string) string { t.Helper() home := t.TempDir() @@ -75,12 +73,9 @@ func setupSpawnHomeGate(t *testing.T, noMistakesPath string) string { return home } -// setupPromoteHomeGate mirrors setupPromoteHome but registers a no-mistakes-mode project and -// skips the worktree/treehouse setup: gatePreflight fires in promote after the report, pane-busy, -// and brief checks but before worktree.Get, so those three preconditions must be satisfied while -// nothing past gatePreflight needs to exist. noMistakesPath becomes PATH verbatim except for the -// fake herdr binary this helper always adds, letting each test control whether no-mistakes is -// reachable. +// Mirrors setupPromoteHome but registers a no-mistakes-mode project and skips the worktree/treehouse +// setup: gatePreflight fires in promote after the report, pane-busy, and brief checks but before +// worktree.Get, so those three must be satisfied while nothing past gatePreflight needs to exist. func setupPromoteHomeGate(t *testing.T, noMistakesPath string) string { t.Helper() useFastLaunchPolling(t) @@ -110,6 +105,8 @@ func setupPromoteHomeGate(t *testing.T, noMistakesPath string) string { t.Fatal(err) } + // noMistakesPath becomes PATH verbatim except for the fake herdr binary this helper always adds, + // letting each test control whether no-mistakes is reachable. herdrBin := t.TempDir() if err := os.WriteFile(filepath.Join(herdrBin, "herdr"), []byte(fakeHerdrPaneDone), 0o755); err != nil { t.Fatal(err) @@ -120,10 +117,9 @@ func setupPromoteHomeGate(t *testing.T, noMistakesPath string) string { return home } -// TestSpawnRefusesWhenNoMistakesGateNotInitialized stands in for both real histories from -// atqamz/secondhand#60 (never-initialized project, and a project whose working_path went stale -// after the fleet home was renamed): both were checked against the real binary and emit the same -// status text, so one refusal test here covers both. +// Stands in for both real histories from atqamz/secondhand#60 (a never-initialized project, and one +// whose working_path went stale after the fleet home was renamed): both were checked against the real +// binary and emit the same status text, so one refusal test covers both. func TestSpawnRefusesWhenNoMistakesGateNotInitialized(t *testing.T) { path := fakeNoMistakesPath(t, "repo not initialized (run 'no-mistakes init' first)") home := setupSpawnHomeGate(t, path) @@ -169,9 +165,9 @@ func TestSpawnProceedsWhenNoMistakesGateReady(t *testing.T) { } } -// TestSpawnSkipGateCheckBypassesRefusalAndWarns pairs the two halves of the escape hatch: the -// not-initialized gate no longer refuses, and the bypass still announces itself on stderr, which -// is the only thing that keeps it visible in a transcript. +// Pairs the two halves of the escape hatch: the not-initialized gate no longer refuses, and the +// bypass still announces itself on stderr, which is the only thing that keeps it visible in a +// transcript. func TestSpawnSkipGateCheckBypassesRefusalAndWarns(t *testing.T) { path := fakeNoMistakesPath(t, "repo not initialized (run 'no-mistakes init' first)") setupSpawnHomeGate(t, path) diff --git a/cmd/hold.go b/cmd/hold.go index 2f8373c..b747d9e 100644 --- a/cmd/hold.go +++ b/cmd/hold.go @@ -72,10 +72,9 @@ func newHoldSetCmd() *cobra.Command { return cmd } -// The limit kind is refused with its own message rather than falling through to -// the generic one: it is a real kind hand status renders and hand spawn honors, -// so an operator who names it deserves to be told who owns it instead of that it -// does not exist. +// The limit kind is refused with its own message rather than falling through to the generic one: it is a +// real kind hand status renders and hand spawn honors, so an operator who names it deserves to be told +// who owns it instead of that it does not exist. func validateHoldKind(kind string) error { switch kind { case state.HoldKindOperator, state.HoldKindBlocked: diff --git a/cmd/init.go b/cmd/init.go index 4d5a41a..b9ab865 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -143,11 +143,11 @@ func initDirs(home string) error { return nil } -// initSkeletonFiles seeds every file it can and reports every one it could -// not, in this fixed order, because the seeds are independent of each other: -// stopping at the first failure named an arbitrary victim, so two runs against -// the same broken home disagreed about which file was at fault. +// Seeds every file it can and reports every one it could not, because the seeds are independent of each +// other. func initSkeletonFiles(home string) error { + // A fixed order: stopping at the first failure named an arbitrary victim, so two runs against the same + // broken home disagreed about which file was at fault. files := []struct { rel string content string @@ -175,9 +175,9 @@ func initSkeletonFiles(home string) error { return errors.Join(errs...) } -// initMarker creates state/hand.db up front so home.IsHome's marker exists as -// soon as init returns, rather than waiting for the first command that -// happens to touch machine state. store.Open is safe to call repeatedly. +// Creates state/hand.db up front so home.IsHome's marker exists as soon as init returns, rather than +// waiting for the first command that happens to touch machine state. store.Open is safe to call +// repeatedly. func initMarker(home string) error { db, err := store.Open(home) if err != nil { @@ -275,9 +275,8 @@ func resolveInitHome(cwd string, args []string) (string, error) { return filepath.Clean(home), nil } -// warnHandHomeMismatch reports the one asymmetry in home handling: init -// creates the home its argument or working directory names, while every other -// command resolves HAND_HOME first, so an operator who exported HAND_HOME and +// Reports the one asymmetry in home handling: init creates the home its argument or working directory +// names, while every other command resolves HAND_HOME first, so an operator who exported HAND_HOME and // initialized somewhere else would otherwise get a home nothing ever uses. func warnHandHomeMismatch(w io.Writer, home string) error { handHome := os.Getenv("HAND_HOME") diff --git a/cmd/launch.go b/cmd/launch.go index 675acc9..6102727 100644 --- a/cmd/launch.go +++ b/cmd/launch.go @@ -8,8 +8,8 @@ import ( "github.com/atqamz/secondhand/internal/herdr" ) -// launchPoll holds confirmLaunch's timings. They are a package var rather than consts so tests -// can shrink the poll window instead of sleeping through a real one. +// Holds confirmLaunch's timings. They are a package var rather than consts so tests can shrink the +// poll window instead of sleeping through a real one. type launchPoll struct { Interval time.Duration QuietReads int @@ -18,41 +18,22 @@ type launchPoll struct { ReadLines int } -// QuietReads * Interval is the settle window a pane must stay dialog-free for, sized to the -// slowest gap between frames seen in testing with real claude, and Timeout is the outer bound -// for a pane that never starts or never clears a dialog. KeySettle gives the harness's TUI time -// to re-render a focus change before the next key arrives; sending a multi-key answer (e.g. -// Down, Enter) in one burst can land the confirm key before the focus move is drawn, confirming -// the wrong option. +// QuietReads * Interval is the settle window a pane must stay dialog-free for, sized to the slowest gap +// between frames seen in testing with real claude, and Timeout is the outer bound for a pane that never +// starts or never clears a dialog. var launchPolling = launchPoll{ Interval: 1 * time.Second, QuietReads: 6, Timeout: 60 * time.Second, - KeySettle: 300 * time.Millisecond, - ReadLines: 60, + // Gives the harness's TUI time to re-render a focus change before the next key arrives; a multi-key + // answer (e.g. Down, Enter) sent in one burst can land the confirm key before the focus move is + // drawn, confirming the wrong option. + KeySettle: 300 * time.Millisecond, + ReadLines: 60, } -// confirmLaunch waits for a freshly launched pane to hold a live harness with no first-run -// dialog left on it before the spawn/promote is reported successful. -// -// Liveness is herdr's answer, not the screen's: herdr reports an agent on a pane only while a -// harness process runs in it, so a harness that painted a dialog and then exited leaves its text -// on screen but no agent, and can never be mistaken for a started worker. Pane text has -// exactly one job here, spotting dialogs: a known prompt is answered and resets the quiet count, -// the generic unrecognized-dialog fallback resets it without being answered so an uncatalogued -// dialog fails loudly, and a prompt catalogued as refused fails immediately with its own reason. -// The text is recent scrollback rather than the visible viewport because an unattached pane is too -// short to show a whole dialog and would clip the very lines that identify it (see herdr.PaneRead). -// That is safe to match against because claude erases an answered first-run dialog in place rather -// than leaving it behind in scrollback, measured against a real spawned worker pane (see SPECS.md). -// A dialog still matching after it was answered is therefore treated as a dialog still up: the -// launch runs out its deadline instead of being confirmed. Ready is a cheap secondary signal - with -// the agent already confirmed present, the harness's own paint leaves the quiet window nothing to -// wait for. -// -// The gate is only as good as herdr's labeling of the harness, which is why a pane that is never -// labeled says so by name (see harness.AgentDetectionVerified) instead of failing as a bare -// timeout. +// Waits for a freshly launched pane to hold a live harness with no first-run dialog left on it before +// the spawn/promote is reported successful. func confirmLaunch(client *herdr.Client, paneID, harnessName string) error { prompts := harness.FirstRunPromptsFor(harnessName) deadline := time.Now().Add(launchPolling.Timeout) @@ -61,6 +42,8 @@ func confirmLaunch(client *herdr.Client, paneID, harnessName string) error { // live agent's composer, so retained text can cost a timeout but never injected keystrokes. answered := map[string]bool{} sawAgent := false + // The gate is only as good as herdr's labeling of the harness, which is why a pane that is never + // labeled says so by name instead of failing as a bare timeout. detected := harness.AgentDetectionVerified(harnessName) noAgent := "the harness never started in the pane" if !detected { @@ -73,6 +56,8 @@ func confirmLaunch(client *herdr.Client, paneID, harnessName string) error { if err != nil { return err } + // Recent scrollback rather than the visible viewport: an unattached pane is too short to show a + // whole dialog and would clip the very lines that identify it (see herdr.PaneRead). text, err := client.PaneRead(paneID, launchPolling.ReadLines) if err != nil { return err @@ -82,6 +67,9 @@ func confirmLaunch(client *herdr.Client, paneID, harnessName string) error { prompt, known := matchFirstRunPrompt(prompts.Known, text) switch { + // Liveness is herdr's answer, not the screen's: herdr reports an agent on a pane only while a + // harness process runs in it, so a harness that painted a dialog and then exited leaves its text + // on screen but no agent, and can never be mistaken for a started worker. case pane.Agent == "": quiet = 0 switch { @@ -94,8 +82,12 @@ func confirmLaunch(client *herdr.Client, paneID, harnessName string) error { default: stall = noAgent } + // A prompt catalogued as refused fails immediately with its own reason. case known && prompt.Refuse != "": return fmt.Errorf("worker is waiting on the %s prompt: %s", prompt.Name, prompt.Refuse) + // Pane text has exactly one job here, spotting dialogs. Matching it is safe because claude erases + // an answered first-run dialog in place rather than leaving it behind in scrollback, measured + // against a real spawned worker pane (see SPECS.md). case known: if !answered[prompt.Name] { if err := answerFirstRunPrompt(client, paneID, prompt); err != nil { @@ -104,10 +96,16 @@ func confirmLaunch(client *herdr.Client, paneID, harnessName string) error { answered[prompt.Name] = true } quiet = 0 + // A dialog still matching after it was answered is treated as a dialog still up: the launch + // runs out its deadline instead of being confirmed. stall = fmt.Sprintf("the %s prompt was answered, its text is still in the pane, and the harness never became ready", prompt.Name) + // The generic fallback resets the quiet count without answering, so an uncatalogued dialog fails + // loudly rather than being confirmed. case prompts.Unrecognized != nil && prompts.Unrecognized.MatchString(text): quiet = 0 stall = "the pane is showing a dialog no known signature covers" + // A cheap secondary signal: with the agent already confirmed present, the harness's own paint + // leaves the quiet window nothing to wait for. case prompts.Ready != nil && prompts.Ready.MatchString(text): return nil default: @@ -125,10 +123,8 @@ func confirmLaunch(client *herdr.Client, paneID, harnessName string) error { } } -// matchFirstRunPrompt reports which catalogued dialog the pane read is showing. A refused prompt -// wins over an answerable one wherever either sits in the catalogue, so a read carrying both -// signatures is never answered into. Answerable entries are taken in catalogue order, which for -// claude is the order it paints them in. +// Reports which catalogued dialog the pane read is showing. A refused prompt wins over an answerable +// one wherever either sits in the catalogue, so a read carrying both signatures is never answered into. func matchFirstRunPrompt(known []harness.FirstRunPrompt, text string) (harness.FirstRunPrompt, bool) { var match harness.FirstRunPrompt found := false @@ -139,6 +135,7 @@ func matchFirstRunPrompt(known []harness.FirstRunPrompt, text string) (harness.F if prompt.Refuse != "" { return prompt, true } + // Answerable entries are taken in catalogue order, which for claude is the order it paints them in. if !found { match, found = prompt, true } diff --git a/cmd/launch_test.go b/cmd/launch_test.go index f2ac1cd..a371e14 100644 --- a/cmd/launch_test.go +++ b/cmd/launch_test.go @@ -11,33 +11,32 @@ import ( "github.com/atqamz/secondhand/internal/herdr" ) -// useFastLaunchPolling shrinks confirmLaunch's poll window for the rest of the test, so a -// command test costs milliseconds instead of sleeping through the real settle window. The -// deadline stays generous because a pane that confirms returns on its own poll and never reaches -// it: every poll here is two real subprocess round-trips into the herdr fake, so a deadline sized -// to a few of them is a race against machine speed rather than a test. Tests that assert the -// deadline itself call expectLaunchTimeout to narrow it back down. +// Shrinks confirmLaunch's poll window for the rest of the test, so a command test costs milliseconds +// instead of sleeping through the real settle window. func useFastLaunchPolling(t *testing.T) { t.Helper() previous := launchPolling launchPolling = launchPoll{ Interval: time.Millisecond, QuietReads: 3, - Timeout: 10 * time.Second, - KeySettle: time.Millisecond, - ReadLines: 60, + // Stays generous because a pane that confirms returns on its own poll and never reaches it: every + // poll here is two real subprocess round-trips into the herdr fake, so a deadline sized to a few + // of them is a race against machine speed rather than a test. + Timeout: 10 * time.Second, + KeySettle: time.Millisecond, + ReadLines: 60, } t.Cleanup(func() { launchPolling = previous }) } -// expectLaunchTimeout narrows the deadline set by an earlier useFastLaunchPolling, whose cleanup -// restores it, for a pane that never confirms and so has to run its deadline out. +// Narrows the deadline set by an earlier useFastLaunchPolling, whose cleanup restores it, for a pane +// that never confirms and so has to run its deadline out. func expectLaunchTimeout() { launchPolling.Timeout = 150 * time.Millisecond } -// launchFrame is one poll's worth of pane state: what "pane read" shows and what agent, if any, -// "pane get" reports running there. An empty agent is a pane holding no harness process. +// One poll's worth of pane state: what "pane read" shows and what agent, if any, "pane get" reports +// running there. An empty agent is a pane holding no harness process. type launchFrame struct { text string agent string @@ -46,10 +45,8 @@ type launchFrame struct { func live(text string) launchFrame { return launchFrame{text: text, agent: "claude"} } func exited(text string) launchFrame { return launchFrame{text: text} } -// fakeLaunchPane installs a herdr fake that replays frames as successive polls, repeating the -// last one once they run out, and appends every key sent to a log the test reads back. A poll is -// a "pane get" followed by a "pane read", so the get arm advances the frame and the read arm -// serves whatever the get arm landed on. +// Installs a herdr fake that replays frames as successive polls, repeating the last one once they run +// out, and appends every key sent to a log the test reads back. func fakeLaunchPane(t *testing.T, frames ...launchFrame) (keyLog string) { t.Helper() dir := t.TempDir() @@ -67,6 +64,8 @@ func fakeLaunchPane(t *testing.T, frames ...launchFrame) (keyLog string) { } } keyLog = filepath.Join(dir, "keys.log") + // A poll is a "pane get" followed by a "pane read", so the get arm advances the frame and the read + // arm serves whatever the get arm landed on. script := `#!/bin/sh last=$(($LAUNCH_FRAME_COUNT - 1)) case "$1 $2" in @@ -214,12 +213,12 @@ func TestConfirmLaunch(t *testing.T) { wantKeys: "Enter\nDown\nEnter\n", }, { - // Issue #28's actual timeline: the pane starts as a bash prompt echoing the launch - // command, claude comes up a beat later on the trust dialog, then settles. pane.Agent - // is tested before the answer branch, so a regression there stops dialog-answering + // atqamz/secondhand#28's actual timeline: the pane starts as a bash prompt echoing the launch + // command, claude comes up a beat later on the trust dialog, then settles. + name: "a dialog that appears after a cold start is still answered", + harness: "claude", + // pane.Agent is tested before the answer branch, so a regression there stops dialog-answering // while every single-frame case above still passes. - name: "a dialog that appears after a cold start is still answered", - harness: "claude", frames: []launchFrame{exited(launchEchoFrame), live(launchTrustFrame), live(launchReadyFrame)}, wantKeys: "Enter\n", }, diff --git a/cmd/merge.go b/cmd/merge.go index 14e69ec..dc27b1c 100644 --- a/cmd/merge.go +++ b/cmd/merge.go @@ -93,9 +93,9 @@ func runPRMerge(cmd *cobra.Command, home string, t state.Task, method string) er return &ExitError{Err: fmt.Errorf("no PR recorded for %s", t.ID), Code: 3} } - // A gate-opened PR (issue #69) can populate t.PR without hand having merged it, - // so t.PR no longer implies hand hasn't seen it merged yet; check before running - // CI checks against a PR gh already closed. + // A gate-opened PR (atqamz/secondhand#69) can populate t.PR without hand having merged it, so t.PR no + // longer implies hand hasn't seen it merged yet; check before running CI checks against a PR gh + // already closed. merged, err := ghutil.PRIsMerged(cmd.Context(), t.PR) if err != nil { return err @@ -204,9 +204,8 @@ func runLocalMerge(cmd *cobra.Command, home string, t state.Task) error { return doc.Render(cmd.OutOrStdout()) } -// prChecksGreen parses `gh pr checks --json bucket` rather than trusting the -// process exit code, since gh's exit codes (0 pass, 8 pending, 1 fail) are -// harder to distinguish reliably across gh versions than the JSON payload. +// Parses `gh pr checks --json bucket` rather than trusting the process exit code, since gh's exit codes +// (0 pass, 8 pending, 1 fail) are harder to distinguish reliably across gh versions than the JSON payload. func prChecksGreen(pr string) (bool, error) { var stdout, stderr bytes.Buffer c := exec.Command("gh", "pr", "checks", pr, "--json", "bucket") diff --git a/cmd/merge_test.go b/cmd/merge_test.go index 2f2f584..b8ca6cc 100644 --- a/cmd/merge_test.go +++ b/cmd/merge_test.go @@ -142,8 +142,7 @@ func TestMergeRefusesWhenNoPRRecorded(t *testing.T) { } } -// TestMergeRefusesAlreadyMergedPR covers cmd/merge.go:90's gap noted in -// atqamz/secondhand#69: a gate-opened PR can populate t.PR without hand having +// Covers the gap noted in atqamz/secondhand#69: a gate-opened PR can populate t.PR without hand having // merged it, so t.PR != "" no longer implies hand hasn't seen it land yet. func TestMergeRefusesAlreadyMergedPR(t *testing.T) { home := t.TempDir() @@ -243,10 +242,9 @@ func TestMergePRSucceedsWhenChecksGreen(t *testing.T) { } } -// hand merge writes the row only after gh has merged, so a fault between the two -// leaves the PR merged and the row saying otherwise. The pre-check is then all that -// stops a rerun re-merging a closed PR, and a repeated `gh pr merge` is exit 0 with -// a warning (internal/faketool/FIDELITY.md), so nothing downstream would notice. +// hand merge writes the row only after gh has merged, so a fault between the two leaves the PR merged +// and the row saying otherwise. The pre-check is then all that stops a rerun, because a repeated +// `gh pr merge` is exit 0 with a warning (internal/faketool/FIDELITY.md) and nothing would notice. func TestMergeRefusesAPRAnEarlierRunAlreadyMerged(t *testing.T) { home := t.TempDir() t.Chdir(home) diff --git a/cmd/pr.go b/cmd/pr.go index e2ad081..76b6817 100644 --- a/cmd/pr.go +++ b/cmd/pr.go @@ -59,10 +59,9 @@ func newPRCmd() *cobra.Command { return cmd } -// recordPR is hand pr's own recording logic, factored out so detectPR (cmd/prdetect.go) -// can route a forge-discovered PR through the same conflict guard and reconciliation -// rather than a second, divergent copy of it. reconcile reports whether url matched -// what was already on t.PR (a no-op on t, since it was already correct). +// hand pr's own recording logic, factored out so detectPR (cmd/prdetect.go) can route a forge-discovered +// PR through the same conflict guard and reconciliation rather than a second, divergent copy of it. The +// bool reports whether url matched what was already on t.PR (a no-op on t, since it was already correct). func recordPR(ctx context.Context, home string, t state.Task, url string) (state.Task, bool, error) { if t.PR != "" && t.PR != url { return t, false, &ExitError{Err: fmt.Errorf("task %s already has a different PR recorded: %s", t.ID, t.PR), Code: 3} diff --git a/cmd/pr_test.go b/cmd/pr_test.go index 847922c..e7f5109 100644 --- a/cmd/pr_test.go +++ b/cmd/pr_test.go @@ -98,14 +98,13 @@ func TestPRRefusesDifferentAlreadyRecordedPR(t *testing.T) { } } -// TestPRReconcilesWhenSameURLAlreadyRecorded pins the reconciling repeat: an -// operator retrying this command after the URL already made it into task -// state gets a friendly no-op instead of an error. The project is deliberately -// unregistered: reaching validation would exit 3, so passing also proves it is -// skipped for a URL already on record. +// Pins the reconciling repeat: an operator retrying this command after the URL already made it into task +// state gets a friendly no-op instead of an error. func TestPRReconcilesWhenSameURLAlreadyRecorded(t *testing.T) { home, _ := setupPRHome(t) url := "https://github.com/a/b/pull/1" + // The project is deliberately unregistered: reaching validation would exit 3, so passing also proves it + // is skipped for a URL already on record. if err := state.Write(home, state.Task{ID: "task-1", Project: "unregistered", PR: url}); err != nil { t.Fatal(err) } @@ -158,11 +157,8 @@ func TestPRRefusesWhenRepoMismatch(t *testing.T) { assertExitCode3(t, err) } -// A fork contribution's PR lives on the upstream repo, never on the fork hand -// pushed to, so the guard has to accept the declared upstream - and only that -// one. The pair is kept in one test so the accepting and the refusing case share -// an identical project, leaving the declaration as the only difference between -// them. +// A fork contribution's PR lives on the upstream repo, never on the fork hand pushed to, so the guard has +// to accept the declared upstream - and only that one. func TestPRAcceptsTheDeclaredUpstreamAndStillRefusesAnyOtherRepo(t *testing.T) { home, clonePath := setupPRHome(t) addOriginRemote(t, clonePath, "https://github.com/atqamz/no-mistakes.git") @@ -194,6 +190,8 @@ func TestPRAcceptsTheDeclaredUpstreamAndStillRefusesAnyOtherRepo(t *testing.T) { t.Fatalf("task.PR = %q, want %q", task.PR, upstreamPR) } + // The pair is kept in one test so the accepting and the refusing case share an identical project, + // leaving the declaration as the only difference between them. unrelated := newPRCmd() unrelated.SetArgs([]string{"task-2", "https://github.com/someone/else/pull/1"}) assertExitCode3(t, unrelated.Execute()) @@ -206,15 +204,13 @@ func TestPRAcceptsTheDeclaredUpstreamAndStillRefusesAnyOtherRepo(t *testing.T) { } } -// A GitHub slug is case-insensitive, so the repo guard has to fold: a PR URL -// carries GitHub's canonical casing while the slug it is checked against comes -// from whatever casing the clone's origin remote and the declared upstream were -// written in. Comparing exactly refuses landed work as a foreign repo, and on -// hand teardown's detection path that surfaces as "no PR recorded" - unlanded, -// the opposite of what happened. Both sides of the guard are covered in one test -// so the fold is pinned for the project's own repo and its upstream alike. +// A GitHub slug is case-insensitive, so the repo guard has to fold: a PR URL carries GitHub's canonical +// casing while the slug it is checked against comes from whatever casing the clone's origin remote and the +// declared upstream were written in. func TestPRAcceptsCanonicalCasingForDifferentlyCasedRemoteAndUpstream(t *testing.T) { home, clonePath := setupPRHome(t) + // Comparing exactly refuses landed work as a foreign repo, and on hand teardown's detection path that + // surfaces as "no PR recorded" - unlanded, the opposite of what happened. addOriginRemote(t, clonePath, "https://github.com/Atqamz/No-Mistakes.git") if err := project.Add(home, project.Project{Name: "demo", URL: "https://github.com/Atqamz/No-Mistakes.git", Mode: project.ModeDirectPR}); err != nil { t.Fatal(err) @@ -222,6 +218,8 @@ func TestPRAcceptsCanonicalCasingForDifferentlyCasedRemoteAndUpstream(t *testing if err := project.SetUpstream(home, "demo", "KunchenGUID/No-Mistakes"); err != nil { t.Fatal(err) } + // Both sides of the guard are covered in one test so the fold is pinned for the project's own repo and + // its upstream alike. for _, id := range []string{"task-1", "task-2"} { if err := state.Write(home, state.Task{ID: id, Project: "demo"}); err != nil { t.Fatal(err) diff --git a/cmd/prdetect.go b/cmd/prdetect.go index 1f5d774..b208b42 100644 --- a/cmd/prdetect.go +++ b/cmd/prdetect.go @@ -10,23 +10,9 @@ import ( "github.com/atqamz/secondhand/internal/state" ) -// detectPR looks for a PR on proj's repo, and on its declared upstream when it has -// one, whose head ref is t's current branch, for a -// task whose PR was never recorded because a no-mistakes gate opened it directly -// instead of going through hand pr (issue #69). It records what it finds through -// recordPR, so a detected PR is subject to the exact same conflict guard and repo -// check hand pr itself enforces, and reuses MergeAnnounced (pr_merged_observed) for -// "already merged, not by hand merge" rather than inventing a new field - the same -// meaning the watcher's own gh poll gives that field for an externally merged PR. -// -// Called only where t.PR == "" already; a task with a PR on record already answers -// this question and never reaches here. -// -// A fork project's PR lives on the upstream while hand pushes the branch to the -// fork (atqamz/secondhand#78), so the upstream is searched too, restricted to head -// refs in the fork - the upstream also carries same-named branches from every other -// contributor's fork. A PR matching in both repos is ambiguous, resolved by -// FindPRByBranch's own tier rule rather than by preferring either repo. +// Looks for a PR whose head ref is t's current branch, for a task whose PR was never recorded because a +// no-mistakes gate opened it directly instead of going through hand pr (atqamz/secondhand#69). Called only +// where t.PR == "" already, so a task with a PR on record never reaches here. func detectPR(ctx context.Context, home string, t state.Task, proj project.Project) (state.Task, error) { branch, err := currentBranch(t.Worktree) if err != nil { @@ -41,8 +27,13 @@ func detectPR(ctx context.Context, home string, t state.Task, proj project.Proje // as the project's own repo in different casing would otherwise be searched // twice and make every PR its own same-tier duplicate. if proj.Upstream != "" && !strings.EqualFold(proj.Upstream, repoSlug) { + // A fork project's PR lives on the upstream while hand pushes the branch to the fork + // (atqamz/secondhand#78), so the upstream is searched too, restricted to head refs in the fork - it + // also carries same-named branches from every other contributor's fork. targets = append(targets, ghutil.PRSearchTarget{Repo: proj.Upstream, HeadRepo: repoSlug}) } + // A PR matching in both repos is ambiguous, resolved by FindPRByBranch's own tier rule rather than by + // preferring either repo. url, merged, found, err := ghutil.FindPRByBranch(ctx, branch, targets...) if err != nil { return t, err @@ -51,8 +42,12 @@ func detectPR(ctx context.Context, home string, t state.Task, proj project.Proje return t, nil } if merged { + // MergeAnnounced (pr_merged_observed) reused for "already merged, not by hand merge" rather than a new + // field - the same meaning the watcher's own gh poll gives it for an externally merged PR. t.MergeAnnounced = true } + // Recorded through recordPR, so a detected PR is subject to the exact same conflict guard and repo + // check hand pr itself enforces. updated, _, err := recordPR(ctx, home, t, url) if err != nil { return t, err @@ -60,19 +55,13 @@ func detectPR(ctx context.Context, home string, t state.Task, proj project.Proje return updated, nil } -// detectPRForStatus is detectPR made safe for a read command: hand status holds no -// lock on the task, so it takes its own non-blocking one - mirroring the watcher's -// own recordAutoPR - and re-reads the task under it in case a concurrent hand pr or -// teardown recorded a PR first. It never fails the command: a task with no branch, -// an unregistered or local-only project, a lock held elsewhere, a branch whose PRs -// are ambiguous, or a failed gh call all just leave t as read, so a forge round trip -// on an already-recorded PR is the only cost this can ever add, and only that task -// pays it once. Unlike hand teardown's landed-work guard, nothing here is gated on -// the answer, so an ambiguous branch degrades like any other detection failure -// instead of refusing. A scout task never answers for a PR - its deliverable is -// data//report.md - so it skips the lookup entirely, the same short-circuit -// checkLandedWork opens with. +// detectPR made safe for a read command: it never fails the command, so a task with no branch, an +// unregistered or local-only project, a lock held elsewhere, an ambiguous branch, or a failed gh call all +// just leave t as read. func detectPRForStatus(ctx context.Context, home string, t state.Task) state.Task { + // A scout task never answers for a PR - its deliverable is data//report.md - so it skips the lookup + // entirely, the same short-circuit checkLandedWork opens with. A forge round trip on an already-recorded + // PR is the only cost this can ever add, and only that task pays it once. if t.PR != "" || t.Kind == state.KindScout { return t } @@ -81,12 +70,15 @@ func detectPRForStatus(ctx context.Context, home string, t state.Task) state.Tas return t } + // hand status holds no lock on the task, so it takes its own non-blocking one, mirroring the watcher's + // own recordAutoPR. unlock, err := state.TryLock(home, "task:"+t.ID) if err != nil { return t } defer unlock() + // Re-read under the lock in case a concurrent hand pr or teardown recorded a PR first. fresh, err := state.Read(home, t.ID) if err != nil { return t @@ -97,6 +89,8 @@ func detectPRForStatus(ctx context.Context, home string, t state.Task) state.Tas ghCtx, cancel := context.WithTimeout(ctx, 5*time.Second) defer cancel() + // Unlike hand teardown's landed-work guard, nothing here is gated on the answer, so an ambiguous branch + // degrades like any other detection failure instead of refusing. detected, err := detectPR(ghCtx, home, fresh, proj) if err != nil { return t diff --git a/cmd/precondition.go b/cmd/precondition.go index 536e976..83cfa21 100644 --- a/cmd/precondition.go +++ b/cmd/precondition.go @@ -8,13 +8,9 @@ import ( "github.com/atqamz/secondhand/internal/state" ) -// preconditionSentinels are errors from internal/state, internal/project, and -// internal/home that SPECS.md classifies as precondition failures (exit code 3) -// rather than general errors (exit code 1). Those packages are imported by cmd, -// so they can't construct ExitError themselves; they signal via these sentinels -// instead. A new sentinel should hold only the trailing phrase and be wrapped as -// fmt.Errorf(" %q ", name, sentinel), matching ErrTaskNotFound and -// ErrNotFound, so each condition renders one consistent string everywhere. +// Errors from internal/state, internal/project, and internal/home that SPECS.md classifies as precondition +// failures (exit code 3) rather than general errors (exit code 1). Those packages are imported by cmd, so +// they cannot construct ExitError themselves and signal via these sentinels instead. var preconditionSentinels = []error{ state.ErrTaskNotFound, state.ErrTaskActive, diff --git a/cmd/project.go b/cmd/project.go index 0bdea1d..56eaebc 100644 --- a/cmd/project.go +++ b/cmd/project.go @@ -241,14 +241,12 @@ func treehouseInitIfNeeded(clonePath string) error { return excludeLocally(clonePath, "treehouse.toml") } -// Excluding the pool config is hand's job because writing it was: treehouse init -// leaves treehouse.toml untracked and ignores it nowhere (internal/faketool/FIDELITY.md), -// so a project whose repo does not list it reads as a dirty clone from then on and -// every later project sync skips it. -// -// info/exclude rather than .gitignore: it is per-clone and never committed, so -// hand cannot leave a change of its own in the operator's repo. +// Excluding the pool config is hand's job because writing it was: treehouse init leaves +// treehouse.toml untracked and ignores it nowhere (internal/faketool/FIDELITY.md), so a project whose +// repo does not list it reads as a dirty clone from then on and every later project sync skips it. func excludeLocally(clonePath, pattern string) error { + // info/exclude rather than .gitignore: it is per-clone and never committed, so hand cannot leave a + // change of its own in the operator's repo. c := exec.Command("git", "-C", clonePath, "rev-parse", "--git-path", "info/exclude") var stderr strings.Builder c.Stderr = &stderr @@ -279,8 +277,8 @@ func excludeLocally(clonePath, pattern string) error { return atomicfile.Write(path, "exclude-", []byte(body+pattern+"\n"), 0o644) } -// projectView is one registry row plus the gate check the row is worth -// reading for, so the column reader never re-runs no-mistakes per field. +// One registry row plus the gate check the row is worth reading for, so the column reader never re-runs +// no-mistakes per field. type projectView struct { project project.Project gateIssue string @@ -374,7 +372,7 @@ func projectListHelp(count, ungated int) []string { return help } -// gateIssue reports why a no-mistakes project's recorded mode cannot currently be honoured, so +// Reports why a no-mistakes project's recorded mode cannot currently be honoured, so // hand project list is the surface an operator catches a stale or missing gate registration on, // instead of a worker discovering it mid-dispatch with nothing obliging it to say so. func gateIssue(home string, p project.Project) string { @@ -526,9 +524,8 @@ func skippedSync(name, detail string) (syncOutcome, error) { return syncOutcome{Name: name, Result: "skipped", Detail: detail}, nil } -// syncOneProject fetches and, when eligible, fast-forwards a single project clone. -// It never errors on a benign skip (dirty, wrong branch, diverged, no remote) - -// those come back as a skipped outcome, per SPECS.md's fail-open policy. +// Fetches and, when eligible, fast-forwards a single project clone. Never errors on a benign skip (dirty, +// wrong branch, diverged, no remote) - those come back as a skipped outcome, per SPECS.md's fail-open policy. func syncOneProject(home string, p project.Project) (syncOutcome, error) { clonePath := filepath.Join(home, "projects", p.Name) @@ -617,9 +614,8 @@ func commitCount(clonePath, revRange string) (int, error) { return n, nil } -// pruneGoneBranches best-effort deletes local branches whose upstream tracking -// branch is gone. Branches still checked out in a worktree refuse deletion; -// that failure is ignored since pruning must never block a sync. +// Best-effort deletes local branches whose upstream tracking branch is gone. Branches still checked out +// in a worktree refuse deletion; that failure is ignored since pruning must never block a sync. func pruneGoneBranches(clonePath string) { c := exec.Command("git", "for-each-ref", "--format=%(refname:short)|%(upstream:track)", "refs/heads/") c.Dir = clonePath diff --git a/cmd/project_sync_test.go b/cmd/project_sync_test.go index a828add..a5e0bb7 100644 --- a/cmd/project_sync_test.go +++ b/cmd/project_sync_test.go @@ -23,8 +23,8 @@ func runGitIn(t *testing.T, dir string, args ...string) { } } -// setupSyncProject creates a bare-ish remote repo and a clone of it, with the -// clone's origin/HEAD already resolvable (as a normal `git clone` sets up). +// Creates a bare-ish remote repo and a clone of it, with the clone's origin/HEAD already resolvable (as a +// normal `git clone` sets up). func setupSyncProject(t *testing.T) (clonePath, remotePath string) { t.Helper() remotePath = filepath.Join(t.TempDir(), "remote") diff --git a/cmd/promote.go b/cmd/promote.go index 128ecef..1ed7d1b 100644 --- a/cmd/promote.go +++ b/cmd/promote.go @@ -165,10 +165,9 @@ func newPromoteCmd() *cobra.Command { TabID: tab.TabID, PaneID: pane.PaneID, } - // Everything below is pane-scoped and so describes a pane this task no longer - // has, none of it evidence about the ship. The report cursor is carried instead: - // promote never touches state/.status, so the report stream is continuous. - // Cleared here rather than left for hand watch, which may not be running. + // Everything below is pane-scoped and so describes a pane this task no longer has, none of it + // evidence about the ship. The report cursor is carried instead: promote never touches + // state/.status, so the stream is continuous. Cleared here rather than left for a watch that may be off. t.DoneVerified = false // The delivery described the scout's report, not the ship run starting // here: left set, teardown would accept the ship task as terminal on a diff --git a/cmd/promote_test.go b/cmd/promote_test.go index a17bf83..af155f4 100644 --- a/cmd/promote_test.go +++ b/cmd/promote_test.go @@ -12,11 +12,9 @@ import ( "github.com/atqamz/secondhand/internal/state" ) -// fakeHerdrPromoteScript covers the herdr calls a clean promote makes, same -// void-command-as-envelope simplification as fakeHerdrSpawnScript in -// spawn_test.go: real "pane run" succeeds with empty stdout, but callVoid also -// accepts this envelope, and promote's own logic only checks for a non-nil -// error, so the exact response shape isn't this test's concern. +// Covers the herdr calls a clean promote makes, same void-command-as-envelope simplification as +// fakeHerdrSpawnScript in spawn_test.go: real "pane run" succeeds with empty stdout, but callVoid takes +// this envelope too, and promote only checks for a non-nil error, so the response shape is not the point. const fakeHerdrPromoteScript = `#!/bin/sh cmd="$1 $2" case "$cmd" in @@ -48,13 +46,9 @@ case "$cmd" in esac ` -// fakeHerdrPromotePaneWorking only fakes "pane get", a query command per -// internal/herdr/client.go's call() doc comment: real success is a non-null -// result object on exit 0, real failure a non-zero exit or an error envelope -// (full contract at cmd/status_test.go's writeFakeHerdrPaneStatus). This fake -// only exercises success, reporting the scout's pane as still "working" so -// promote's precondition check refuses the promotion before any other herdr -// call is needed; it mirrors the real success shape. +// Fakes only a successful "pane get" in the real shape (the query-command contract is at +// internal/herdr/client.go's call doc), reporting the scout's pane as still "working" so promote's +// precondition check refuses the promotion before any other herdr call is needed. const fakeHerdrPromotePaneWorking = `#!/bin/sh cmd="$1 $2" case "$cmd" in @@ -303,12 +297,9 @@ func TestPromoteRefusesUnregisteredProject(t *testing.T) { } } -// fakeHerdrPromoteLeakScript mirrors fakeHerdrLeakScript for promote: it logs every call and -// fails "pane run" so the promotion always fails after the new tab exists, with +// Mirrors fakeHerdrLeakScript in spawn_test.go for promote, its bare-exit-1 simplification included: it +// logs every call and fails "pane run" so the promotion always fails after the new tab exists, with // $HERDR_WS_EXISTS_FLAG choosing between the created-workspace and pre-existing-workspace cases. -// Same bare-exit-1 simplification as fakeHerdrLeakScript in spawn_test.go: promote.go only -// checks PaneRun's error for non-nil, not its shape, and the real exit-0-plus-error-envelope -// failure shape is covered at the client level, not here. const fakeHerdrPromoteLeakScript = `#!/bin/sh echo "$@" >> "$HERDR_CALL_LOG" cmd="$1 $2" diff --git a/cmd/root.go b/cmd/root.go index 49c74f2..258b2c4 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -64,15 +64,14 @@ func newRootCmd(version string) *cobra.Command { return root } -// guardSubcommandGroups makes every subcommand-only group below c reject an -// unknown subcommand with exit code 2. A group with no RunE trips cobra's -// Runnable() check, which short-circuits to a help dump and a zero exit before -// the group's Args validator ever runs, so the group needs both. Root is left -// alone: cobra's Find() already reports its unknown commands, and giving it a -// non-nil Args would suppress that. +// Makes every subcommand-only group below c reject an unknown subcommand with exit code 2. func guardSubcommandGroups(c *cobra.Command) { + // Root itself is left alone: cobra's Find() already reports its unknown commands, and giving it a + // non-nil Args would suppress that. for _, sub := range c.Commands() { if sub.HasSubCommands() && !sub.Runnable() { + // A group with no RunE trips cobra's Runnable() check, which short-circuits to a help dump and a + // zero exit before the group's Args validator ever runs, so the group needs both. sub.Args = usageArgs(cobra.NoArgs) sub.RunE = func(cmd *cobra.Command, args []string) error { return cmd.Help() } } @@ -133,9 +132,8 @@ func usageArgs(validate cobra.PositionalArgs) cobra.PositionalArgs { } } -// usageValue tags a rejected input value as exit code 2 only when it came from -// the command line. The same value read from a config/ default is a general -// error (code 1): nothing the invocation said was wrong. +// Tags a rejected input value as exit code 2 only when it came from the command line. The same value read +// from a config/ default is a general error (code 1): nothing the invocation said was wrong. func usageValue(fromFlag bool, err error) error { if fromFlag { return &ExitError{Err: err, Code: 2} @@ -209,13 +207,12 @@ func errorHelp(code int, path string) []string { return nil } -// ExitError carries a non-default exit code that SPECS.md requires: 2 for a -// usage error (bad arg count, unknown flag, unknown subcommand, invalid -// argument or flag value) and 3 for a precondition failure like red CI or -// uncommitted changes, both distinct from the general-error code (1) cobra -// otherwise produces for any RunE error. +// ExitError carries a non-default exit code SPECS.md's "Exit codes" table defines, distinct from the +// general-error code (1) cobra otherwise produces for any RunE error. type ExitError struct { - Err error + Err error + // 2 for a usage error (bad arg count, unknown flag, unknown subcommand, invalid argument or flag + // value) and 3 for a precondition failure like red CI or uncommitted changes. Code int } diff --git a/cmd/send.go b/cmd/send.go index 51cd654..7036999 100644 --- a/cmd/send.go +++ b/cmd/send.go @@ -59,11 +59,9 @@ func newSendCmd() *cobra.Command { return usageValue(waitFromFlag, fmt.Errorf("invalid wait duration %q: %w", wait, err)) } - // Serializes concurrent sends to the same task: two retry loops racing - // against the same busy composer is the exact hazard atqamz/secondhand#102 - // traced a lost steer to. A second send now waits behind the first - // rather than polling the same pane at the same time, its own - // --wait clock starting only once it holds this lock. + // Serializes concurrent sends to the same task: two retry loops racing against the same busy + // composer is the exact hazard atqamz/secondhand#102 traced a lost steer to. A second send waits + // behind the first, its own --wait clock starting only once it holds this lock. release, err := state.Lock(home, "send:"+id) if err != nil { return fmt.Errorf("lock send %q: %w", id, err) @@ -92,11 +90,9 @@ func newSendCmd() *cobra.Command { if err := recordUndeliveredSend(home, id, message); err != nil { return fmt.Errorf("%w; record undelivered send: %w", waitErr, err) } - // Code 6: the composer stayed busy for the whole --wait bound, so - // the message never reached the pane - a transient state a caller - // can retry, distinct from the exit-1 paths above and below that - // mean the send can never succeed (no such pane, herdr itself - // erroring). 4 and 5 are reserved to hand watch --until-event. + // Code 6: the composer stayed busy for the whole --wait bound, so the message never reached + // the pane - a transient a caller can retry, distinct from the exit-1 paths above and below + // that can never succeed (no such pane, herdr erroring). 4 and 5 are hand watch --until-event's. return &ExitError{Err: fmt.Errorf("%w, message recorded as undelivered", waitErr), Code: 6} } } @@ -133,9 +129,8 @@ func newSendCmd() *cobra.Command { return cmd } -// withUndeliveredSend records the trace alongside a delivery failure, keeping -// the cause as the returned error so the exit code of the failing path is -// unchanged. +// Records the trace alongside a delivery failure, keeping the cause as the returned error so the exit +// code of the failing path is unchanged. func withUndeliveredSend(home, id, message string, cause error) error { if err := recordUndeliveredSend(home, id, message); err != nil { return fmt.Errorf("%w; record undelivered send: %w", cause, err) @@ -143,24 +138,21 @@ func withUndeliveredSend(home, id, message string, cause error) error { return cause } -// recordUndeliveredSend durably records the message hand send could not -// demonstrably deliver, so an operator or worker learns the steer never -// arrived instead of it vanishing with the process that attempted it. A -// short-lived task-row lock, separate from the send lock held for the whole -// wait above, so a long busy wait never blocks an unrelated reader like hand -// watch or hand status. +// Durably records the message hand send could not demonstrably deliver, so an operator or worker learns +// the steer never arrived instead of it vanishing with the process that attempted it. func recordUndeliveredSend(home, id, message string) error { return setUndeliveredSend(home, id, message, time.Now().UTC().Format(time.RFC3339)) } -// clearUndeliveredSend runs after every send that actually reaches the pane, -// whatever message that send carries: the trace's job is telling the operator -// their last attempt did not land, and any successful send moots it. +// Runs after every send that actually reaches the pane, whatever message that send carries: the trace's +// job is telling the operator their last attempt did not land, and any successful send moots it. func clearUndeliveredSend(home, id string) error { return setUndeliveredSend(home, id, "", "") } func setUndeliveredSend(home, id, message, at string) error { + // A short-lived task-row lock, separate from the send lock the command holds across its whole wait, + // so a long busy wait never blocks an unrelated reader like hand watch or hand status. release, err := state.Lock(home, "task:"+id) if err != nil { return fmt.Errorf("lock task %q: %w", id, err) diff --git a/cmd/send_test.go b/cmd/send_test.go index 33c6ac7..f42124a 100644 --- a/cmd/send_test.go +++ b/cmd/send_test.go @@ -25,10 +25,9 @@ func setupSendHome(t *testing.T, herdrScript string) string { } func TestSendHappyPathWhenIdle(t *testing.T) { - // "pane send-text"/"pane send-keys" are void commands: real success is - // empty stdout, not this envelope (callVoid's doc comment, client.go). - // callVoid only checks env.Error, which is nil here, so the extra body is - // harmless and this still exercises the real success path. + // "pane send-text"/"pane send-keys" are void commands: real success is empty stdout, not this envelope + // (callVoid's doc comment, client.go). callVoid only checks env.Error, which is nil here, so the extra + // body is harmless and this still exercises the real success path. home := setupSendHome(t, `#!/bin/sh cmd="$1 $2" case "$cmd" in @@ -59,10 +58,9 @@ esac } func TestSendFailsWhenPaneNotFound(t *testing.T) { - // "pane get" is a query command (call(), client.go); call() checks - // env.Error before runErr, so this fake would behave identically without - // the exit 1 - kept here only because it is a plausible real exit status - // for a failed query, not because call() requires it. + // "pane get" is a query command (call(), client.go); call() checks env.Error before runErr, so this fake + // would behave identically without the exit 1 - kept only because it is a plausible real exit status for + // a failed query, not because call() requires it. home := setupSendHome(t, `#!/bin/sh printf '{"id":"cli:1","error":{"code":"pane_not_found","message":"no such pane"}}' exit 1 @@ -380,10 +378,9 @@ esac } func TestSendFailsForUnknownTask(t *testing.T) { - // send resolves the task before it ever reaches herdr, so this fake refuses - // every invocation instead of imitating a herdr response: a regression that - // called herdr first would surface that message here rather than the - // expected "not found". + // send resolves the task before it ever reaches herdr, so this fake refuses every invocation instead of + // imitating a herdr response: a regression that called herdr first would surface that message here + // rather than the expected "not found". setupSendHome(t, `#!/bin/sh echo "unexpected herdr invocation: $@" >&2 exit 1 diff --git a/cmd/spawn.go b/cmd/spawn.go index 34126cf..1bd0059 100644 --- a/cmd/spawn.go +++ b/cmd/spawn.go @@ -58,12 +58,9 @@ func newSpawnCmd() *cobra.Command { } defer releaseClaim() - // A hold outlives the task row it was set on, by design, so a torn-down - // task's open question stays visible. Reusing the id for new work would - // silently reattach that question to an unrelated task, so refuse here - // rather than clearing it: an operator hold records a question answering it - // is an acknowledgement hand has no business making, and a limit hold means - // a worker is still on this id, merely out of quota. + // A hold outlives the task row it was set on, by design, so a torn-down task's open question stays + // visible, and reusing the id would silently reattach it to unrelated work. Refused rather than + // cleared: answering an operator hold is not hand's, and a limit hold means a worker is still here. held, hasHold, err := state.ReadHold(home, id) if err != nil { return asPrecondition(err) @@ -119,9 +116,8 @@ func newSpawnCmd() *cobra.Command { return reportSpawnCleanup(err, worktree.Return(wt, true)) } - // spawned disarms the rollback below once state.Write has durably recorded the - // task: from that point its workspace and tab are owned by the running task, not - // by this call. + // Disarms the rollback below once state.Write has durably recorded the task: from that point + // its workspace and tab are owned by the running task, not by this call. spawned := false defer func() { if spawned { @@ -202,22 +198,22 @@ func newSpawnCmd() *cobra.Command { return cmd } -// gatePreflight refuses to dispatch into a no-mistakes project whose gate is not initialized, -// rather than letting the worker discover it mid-run with nothing obliged to report it. It asks -// the no-mistakes binary rather than reading its private state.sqlite, so a stale or missing gate -// registration (renamed working_path, never-initialized repo) is caught here instead of silently -// producing an ungated "done". skipGateCheck is the escape hatch for a project mid-migration; it -// still prints so bypassing it is visible, not silent. +// Refuses to dispatch into a no-mistakes project whose gate is not initialized, rather than letting +// the worker discover it mid-run with nothing obliged to report it. func gatePreflight(cmd *cobra.Command, proj project.Project, clonePath string, skipGateCheck bool) error { if proj.Mode != project.ModeNoMistakes { return nil } + // The escape hatch for a project mid-migration; it still prints so bypassing it stays visible. if skipGateCheck { if _, err := fmt.Fprintf(cmd.ErrOrStderr(), "warning: --skip-gate-check bypassing the no-mistakes gate check for project %q\n", proj.Name); err != nil { return err } return nil } + // Asks the no-mistakes binary rather than reading its private state.sqlite, so a stale or missing + // gate registration (renamed working_path, never-initialized repo) is caught here instead of + // silently producing an ungated "done". gateState, err := project.GateStatus(clonePath) if err != nil { return fmt.Errorf("check no-mistakes gate for project %q: %w", proj.Name, err) @@ -228,14 +224,13 @@ func gatePreflight(cmd *cobra.Command, proj project.Project, clonePath string, s return nil } -// herdrWorkspaceLabel is the label hand gives, and searches for, a project's shared workspace. -// A bare project name is not a safe search key: herdr derives a workspace's label from its root -// directory's basename, so any workspace a human opens under a directory named after the project -// carries the same label and would otherwise be a candidate (atqamz/secondhand#118). Prefixing it -// the same way worktree.Get's pool name already does ("hand:"+id) makes the label one only hand -// itself would ever set, so a same-labelled workspace hand did not create can never match, no -// matter how the herdr's workspace list happens to be ordered. +// The label hand gives, and searches for, a project's shared workspace. A bare project name is not a +// safe search key: herdr derives a workspace's label from its root directory's basename, so any +// workspace a human opens under a directory named after the project carries it (atqamz/secondhand#118). func herdrWorkspaceLabel(projName string) string { + // Prefixed the same way worktree.Get's pool name already does ("hand:"+id), so the label is one only + // hand itself would ever set and a same-labelled workspace hand did not create can never match, no + // matter how the herdr's workspace list happens to be ordered. return "hand:" + projName } @@ -269,18 +264,18 @@ func acquireTaskWorkspace(client *herdr.Client, wt, id, projName string) (herdr. return ws, tab, pane, rollback, nil } -// acquireTaskTab returns the tab and pane a spawn-shaped lifecycle should use for the task. herdr -// has no way to create an empty workspace: workspace create always creates a root tab and pane at -// its cwd too, so a task that just created the workspace reuses that root tab (renamed to id) -// instead of creating a second one, which would leave the root tab behind as an orphan shell. A -// task landing in an already-existing workspace still creates its own tab. +// Returns the tab and pane a spawn-shaped lifecycle should use for the task. func acquireTaskTab(client *herdr.Client, createdWorkspace bool, workspaceID, wt, id string, rootTab herdr.Tab, rootPane herdr.Pane) (herdr.Tab, herdr.Pane, error) { + // herdr has no way to create an empty workspace: workspace create always creates a root tab and pane + // at its cwd too, so a task that just created the workspace reuses that root tab (renamed to id) + // rather than creating a second one, which would leave the root tab behind as an orphan shell. if createdWorkspace { if err := client.TabRename(rootTab.TabID, id); err != nil { return herdr.Tab{}, herdr.Pane{}, fmt.Errorf("herdr tab rename failed: %w", err) } return rootTab, rootPane, nil } + // A task landing in an already-existing workspace still creates its own tab. tab, pane, err := client.TabCreate(workspaceID, wt, id) if err != nil { return herdr.Tab{}, herdr.Pane{}, fmt.Errorf("herdr tab create failed: %w", err) @@ -288,17 +283,17 @@ func acquireTaskTab(client *herdr.Client, createdWorkspace bool, workspaceID, wt return tab, pane, nil } -// rollbackHerdr undoes the herdr side of a failed spawn-shaped lifecycle: a workspace this call -// created goes away whole, because its only tab is the root tab acquireTaskTab renames into the -// task's, and a failure before that rename leaves no tabID to close it by. A pre-existing -// workspace is shared with other tasks and only loses the tab this call added to it. +// Undoes the herdr side of a failed spawn-shaped lifecycle. func rollbackHerdr(client *herdr.Client, createdWorkspace bool, workspaceID, tabID string) error { + // A workspace this call created goes away whole, because its only tab is the root tab acquireTaskTab + // renames into the task's, and a failure before that rename leaves no tabID to close it by. if createdWorkspace { return client.WorkspaceClose(workspaceID) } if tabID == "" { return nil } + // A pre-existing workspace is shared with other tasks and only loses the tab this call added to it. return closeTaskTab(client, workspaceID, tabID) } diff --git a/cmd/spawn_test.go b/cmd/spawn_test.go index 8fa560e..834a7d5 100644 --- a/cmd/spawn_test.go +++ b/cmd/spawn_test.go @@ -27,14 +27,9 @@ func TestSpawnCleanupReportsAllErrors(t *testing.T) { } } -// fakeHerdrSpawnScript covers the herdr calls a clean spawn makes: it reports a pane herdr sees -// claude running in, painted past its startup frame and showing no first-run dialog, so -// confirmLaunch confirms the launch on its first poll. Real herdr answers query commands -// ("workspace list", "tab create", "pane get") with a JSON envelope and answers void commands -// ("pane run") with empty stdout on success (internal/herdr/client.go's call/callVoid doc -// comments); this fake echoes an envelope for "pane run" too, which callVoid also accepts, since -// this test exercises spawn's own success path, not herdr's response parsing - that parsing is -// covered at the client level by internal/herdr/client_test.go. +// Covers the herdr calls a clean spawn makes: a pane herdr sees claude running in, painted past its +// startup frame with no first-run dialog, so confirmLaunch confirms on its first poll. It echoes an +// envelope for the void "pane run" too, which callVoid accepts (real shapes: internal/faketool/FIDELITY.md). const fakeHerdrSpawnScript = `#!/bin/sh cmd="$1 $2" case "$cmd" in @@ -60,16 +55,13 @@ case "$cmd" in esac ` -// writeFakeTreehouseGet fakes "treehouse get" as always leasing worktreePath. -// Real treehouse writes a banner to stderr ahead of its JSON on "get" -// (internal/worktree/worktree.go's Get doc comment); this fake omits it since -// worktree.Get's own stdout-only parsing is covered where the banner matters, -// in tests/e2e/fakes_test.go's writeFakeTreehouse. It does mint a fresh -// lease_id per invocation, because that - not the recycled slot path - is what -// real treehouse guarantees is unique per acquisition, and it is what the spawn -// collision guard keys on. +// Fakes "treehouse get" as always leasing worktreePath. Real treehouse writes a banner to stderr ahead +// of its JSON on "get" (internal/worktree/worktree.go's Get doc); omitted here since +// tests/e2e/fakes_test.go's writeFakeTreehouse covers the stdout-only parsing where the banner matters. func writeFakeTreehouseGet(t *testing.T, bin, worktreePath string) { t.Helper() + // A fresh lease_id per invocation, because that - not the recycled slot path - is what real treehouse + // guarantees unique per acquisition, and what the spawn collision guard keys on. counter := filepath.Join(bin, ".treehouse-leases") script := "#!/bin/sh\nn=$(cat " + counter + " 2>/dev/null || echo 0)\nn=$((n+1))\necho \"$n\" > " + counter + "\nprintf '{\"path\":\"" + worktreePath + "\",\"lease_id\":\"lease-%s\"}' \"$n\"\n" @@ -135,12 +127,9 @@ func TestSpawnHappyPath(t *testing.T) { } } -// TestSpawnIgnoresSameLabelledWorkspaceHandDidNotCreate pins the fix for atqamz/secondhand#118: -// herdr derives a workspace's label from its root directory's basename, so a human who opens a -// directory named after the project gets a workspace sharing hand's own bare-label search key. -// The plain-labelled "myproj" workspace here sorts first in "workspace list" and would have won -// under the old bare-label lookup; hand must still resolve to its own "hand:myproj" workspace -// regardless of list order, never the one it did not create. +// Pins atqamz/secondhand#118: the plain-labelled "myproj" workspace here sorts first in "workspace list" +// and would have won the old bare-label lookup, so hand must resolve to its own "hand:myproj" whatever +// the order, never one it did not create (how a human's workspace collides: internal/faketool/FIDELITY.md). const fakeHerdrTwoWorkspacesOneLabelScript = `#!/bin/sh cmd="$1 $2" case "$cmd" in @@ -371,15 +360,9 @@ func TestSpawnAllowsAReusedWorktreePathUnderAFreshLease(t *testing.T) { } } -// fakeHerdrLeakScript logs every invocation to $HERDR_CALL_LOG and fails "pane run" so a -// spawn always fails after tab creation. Whether "workspace list" reports an existing -// workspace is controlled by the presence of $HERDR_WS_EXISTS_FLAG, letting the same script -// drive both the created-workspace and pre-existing-workspace leak scenarios. -// "pane run" fails via bare exit 1 rather than real herdr's documented void-command -// failure shape (empty exit 0 + JSON error envelope, see callVoid's doc comment): spawn.go -// only branches on whether PaneRun returned a non-nil error, never on its shape, so this -// tests spawn's cleanup logic, not herdr's envelope parsing - that shape is covered by -// internal/herdr/client_test.go's TestPaneRunSurfacesErrorEnvelopeEvenOnExitZero. +// Logs every invocation to $HERDR_CALL_LOG and fails "pane run" so a spawn always fails after tab +// creation, with $HERDR_WS_EXISTS_FLAG driving both leak scenarios, created and pre-existing. Bare exit +// 1, not herdr's void-failure shape: spawn.go branches only on whether PaneRun errored (client_test.go). const fakeHerdrLeakScript = `#!/bin/sh echo "$@" >> "$HERDR_CALL_LOG" cmd="$1 $2" @@ -434,8 +417,8 @@ func setupSpawnLeakEnv(t *testing.T, workspaceExists bool) string { return callLog } -// fakeHerdrStuckPaneScript launches fine but reports a pane sitting on a dialog nothing -// answers, the shape confirmLaunch must fail rather than record as a spawned task. +// Launches fine but reports a pane sitting on a dialog nothing answers, the shape confirmLaunch must +// fail rather than record as a spawned task. const fakeHerdrStuckPaneScript = `#!/bin/sh echo "$@" >> "$HERDR_CALL_LOG" cmd="$1 $2" @@ -513,11 +496,9 @@ func TestSpawnFailureClosesWorkspaceItCreated(t *testing.T) { } } -// fakeHerdrPartialWorkspaceCreateScript answers "workspace create" the way a herdr whose protocol -// predates the tab/root_pane fields on workspace_created would: it reports the workspace but -// omits both, the partial-response shape atqamz/secondhand#74 fixes. herdr has still created the -// workspace by the time this responds, so a faithful fake must also accept "workspace close" and -// log it, or a test using this script could pass with the leak fix reverted. +// Answers "workspace create" as a herdr predating the tab/root_pane fields would, reporting the workspace +// and omitting both - the partial-response shape atqamz/secondhand#74 fixes. It accepts and logs +// "workspace close" too, since the workspace exists by then and a test could otherwise pass unfixed. const fakeHerdrPartialWorkspaceCreateScript = `#!/bin/sh echo "$@" >> "$HERDR_CALL_LOG" cmd="$1 $2" @@ -562,10 +543,9 @@ func TestSpawnPartialWorkspaceCreateLeavesNoWorkspaceBehind(t *testing.T) { } } -// fakeHerdrTabRenameFailureScript answers "workspace create" in full and then fails the rename of -// the root tab into the task's - the one failure that lands between herdr creating the workspace -// and the caller arming its own rollback, so the workspace has to be closed by the acquisition -// step itself. +// Answers "workspace create" in full and then fails the rename of the root tab into the task's - the +// one failure that lands between herdr creating the workspace and the caller arming its own rollback, +// so the workspace has to be closed by the acquisition step itself. const fakeHerdrTabRenameFailureScript = `#!/bin/sh echo "$@" >> "$HERDR_CALL_LOG" cmd="$1 $2" diff --git a/cmd/status.go b/cmd/status.go index 4a83786..a6b7333 100644 --- a/cmd/status.go +++ b/cmd/status.go @@ -58,13 +58,9 @@ func newStatusCmd() *cobra.Command { return cmd } -// reportSummaryBudget bounds the rendered length of one report line (state -// prefix plus note) in the human-readable single-task view, in runes so a -// multi-byte character never lands half-cut. A worker's status prose has run -// 2.7-4.3 KB for a single task; this keeps a normal terse report (which is -// what the vocabulary in CLAUDE.md/AGENTS.md asks for) untouched while -// bounding the pathological case. --json and --full both bypass it, since a -// machine consumer needs the whole field and --full is the explicit opt-out. +// Bounds the rendered length of one report line (state prefix plus note) in the human-readable +// single-task view, in runes so a multi-byte character never lands half-cut. A worker's status prose has +// run 2.7-4.3 KB for a single task, and the terse report AGENTS.md asks for fits well inside this. const reportSummaryBudget = 200 // The state-vocabulary prefix ("done: ", "blocked: ", ...) is never part of @@ -78,9 +74,9 @@ func truncateReportLine(line state.ReportLine, budget int, id string) string { return axi.Truncate(reportLineText(line), max(budget, prefixLen), "hand status "+id+" --full") } -// reportSummary renders the last report line the way both status views show -// it: an unreadable channel named as such, and the unacknowledged clause on the -// classified line the fleet view flags rather than on trailing free text. +// Renders the last report line the way both status views show it: an unreadable channel named as such, +// and the unacknowledged clause on the classified line the fleet view flags rather than on trailing free +// text. func reportSummary(id string, lines []state.ReportLine, readErr error, unacked, full bool) string { if readErr != nil { return fmt.Sprintf("report %s: %v", reportUnreadable, readErr) @@ -95,14 +91,16 @@ func reportSummary(id string, lines []state.ReportLine, readErr error, unacked, line, suffix = classified, " (unacknowledged)" } } + // --full is the explicit opt-out from reportSummaryBudget, and --json bypasses it as well: a machine + // consumer needs the whole field. if full { return reportLineText(line) + suffix } return truncateReportLine(line, reportSummaryBudget, id) + suffix } -// paneAgentStatus degrades gracefully to "unknown" when herdr is unreachable or the -// pane can't be queried, per SPECS.md's fail-open policy for read operations. +// Degrades gracefully to "unknown" when herdr is unreachable or the pane cannot be queried, per +// SPECS.md's fail-open policy for read operations. func paneAgentStatus(client *herdr.Client, paneID string) string { if paneID == "" { return string(herdr.StatusUnknown) @@ -114,9 +112,8 @@ func paneAgentStatus(client *herdr.Client, paneID string) string { return string(pane.AgentStatus) } -// reportedJSON mirrors one classified line from state.ReportLine for JSON -// output; Malformed lines carry their raw text in Note with State left empty, -// and an unreadable report file carries the read error in Note under the +// Mirrors one classified line from state.ReportLine for JSON output: malformed lines carry their raw text +// in Note with State left empty, and an unreadable report file carries the read error in Note under the // reportUnreadable state. type reportedJSON struct { State string `json:"state"` @@ -147,20 +144,18 @@ type statusJSON struct { Unacknowledged bool `json:"unacknowledged,omitempty"` } -// fleetJSON wraps the task rows with the fleet's holds, which name any id - -// not only a live task - so a torn-down task's still-open hold keeps -// surfacing here after its task row is gone. TaskCount is always present, -// zero included, so an empty fleet is a positive statement ("no tasks") and -// not the same absence of output a broken command would also produce. +// Wraps the task rows with the fleet's holds, which name any id - not only a live task - so a torn-down +// task's still-open hold keeps surfacing here after its task row is gone. type fleetJSON struct { + // Always present, zero included, so an empty fleet is a positive statement ("no tasks") and not the + // same absence of output a broken command would also produce. TaskCount int `json:"task_count"` Tasks []statusJSON `json:"tasks"` Holds []holdJSON `json:"holds"` } -// holdJSON mirrors state.Hold, plus Inconsistent, which is set instead of the -// row being dropped when a value can't be trusted at face value - see -// holdInconsistency. +// Mirrors state.Hold, plus Inconsistent, which is set instead of the row being dropped when a value +// cannot be trusted at face value - see holdInconsistency. type holdJSON struct { ID string `json:"id"` Kind string `json:"kind"` @@ -170,15 +165,12 @@ type holdJSON struct { Inconsistent string `json:"inconsistent,omitempty"` } -// holdInconsistency names why a hold row can't be trusted at face value, so -// that ListHolds surfacing every row (rather than filtering) turns into a -// visible flag instead of a silently wrong render: an unrecognized kind, a -// blocked hold with nothing to point at, or an operator or limit hold carrying -// a blocked_on nothing set. Nothing in this codebase writes such a row today - -// hand hold set validates first, and hand watch's limit holds set no -// blocked_on at all - so seeing one here means something outside hand touched -// state/hand.db directly. +// Names why a hold row cannot be trusted at face value, so that ListHolds surfacing every row (rather +// than filtering) turns into a visible flag instead of a silently wrong render. func holdInconsistency(h state.Hold) string { + // An unrecognized kind, a blocked hold with nothing to point at, or an operator or limit hold carrying + // a blocked_on nothing set. Nothing here writes such a row today - hand hold set validates first, and + // limit holds set no blocked_on - so one means something outside hand touched state/hand.db directly. switch h.Kind { case state.HoldKindOperator: if h.BlockedOn != "" { @@ -207,9 +199,8 @@ func holdToJSON(h state.Hold) holdJSON { } } -// holdDetail renders a hold's non-identifying fields for the plain-text held -// block. An inconsistency takes over the whole line: a garbled blocked-on or -// reason next to it would read as a valid detail rather than as the flag it is. +// Renders a hold's non-identifying fields for the plain-text held block. An inconsistency takes over the +// whole line: a garbled blocked-on or reason next to it would read as a valid detail rather than a flag. func holdDetail(h state.Hold) string { if inc := holdInconsistency(h); inc != "" { return "inconsistent: " + inc @@ -220,21 +211,19 @@ func holdDetail(h state.Hold) string { return h.Reason } -// gateRunApplies is the single predicate for whether the gate-run check has anything to say about a -// task: only a done ship task with a recorded PR does. Everything the check needs - the project -// lookup above all, whose failure the single-task view propagates - hangs off this, so a task the -// check would stay silent on never pays that cost nor fails over it. +// The single predicate for whether the gate-run check has anything to say about a task: only a done ship +// task with a recorded PR does. Everything the check needs - the project lookup above all, whose failure +// the single-task view propagates - hangs off this, so a silent task never pays that cost nor fails over it. func gateRunApplies(t state.Task, reportedDone bool) bool { return t.Kind == state.KindShip && t.PR != "" && reportedDone } -// gateRunReader answers "which PRs did completed no-mistakes runs record" for one clone path. +// Answers "which PRs did completed no-mistakes runs record" for one clone path. type gateRunReader func(clonePath string) (map[string]bool, error) -// newGateRunReader caches each clone path's answer for the life of one render, so a fleet with -// several done ship tasks on the same project spawns one no-mistakes process for it, not one per -// task. Failures are cached too: a clone that could not be asked once is not worth re-asking within -// the same render. +// Caches each clone path's answer for the life of one render, so a fleet with several done ship tasks on +// the same project spawns one no-mistakes process for it, not one per task. Failures are cached too: a +// clone that could not be asked once is not worth re-asking within the same render. func newGateRunReader() gateRunReader { type answer struct { prs map[string]bool @@ -251,23 +240,21 @@ func newGateRunReader() gateRunReader { } } -// gateRunIssue reports why a done ship task's recorded PR cannot be confirmed to have gone through a -// no-mistakes gate run, using the same "unreachable" bucket gateIssue (cmd/project.go) uses for any -// failure to ask no-mistakes at all - a missing clone, an unrunnable binary, a gate never -// initialized - so a question this check cannot answer never renders as the stronger claim "no run -// found". -// -// A project not registered or not run through no-mistakes stays silent alongside every task -// gateRunApplies rejects, since the check does not apply to it either. +// Reports why a done ship task's recorded PR cannot be confirmed to have gone through a no-mistakes gate +// run, using the same "unreachable" bucket gateIssue (cmd/project.go) uses for any failure to ask +// no-mistakes at all, so a question this check cannot answer never renders as "no run found". func gateRunIssue(home string, t state.Task, reportedDone bool, p project.Project, registered bool, runPRs gateRunReader) string { if !gateRunApplies(t, reportedDone) { return "" } + // A project not registered or not run through no-mistakes stays silent alongside every task + // gateRunApplies rejects, since the check does not apply to it either. if !registered || p.Mode != project.ModeNoMistakes { return "" } prs, err := runPRs(filepath.Join(home, "projects", p.Name)) if err != nil { + // A missing clone, an unrunnable binary, or a gate never initialized all land in this bucket. return "unreachable" } if !prs[t.PR] { @@ -301,8 +288,8 @@ func runStatusFleet(cmd *cobra.Command, home string, client *herdr.Client, asJSO return doc.Render(cmd.OutOrStdout()) } -// appendFleet writes the fleet blocks onto doc rather than a writer, so the -// bare command can put its identity fields above the same overview. +// Writes the fleet blocks onto doc rather than a writer, so the bare command can put its identity fields +// above the same overview. func appendFleet(doc *axi.Doc, views []taskView, holds []state.Hold, cols []axi.Column[taskView]) { attention := 0 for _, v := range views { @@ -331,13 +318,12 @@ func fleetViews(cmd *cobra.Command, home string, client *herdr.Client) ([]taskVi return nil, nil, err } - // Best-effort, like project.List elsewhere in this fleet view: a registry - // read fault degrades every task's gate-run check to silent rather than - // failing the whole fleet overview over it. Named on stderr all the same - - // silently dropping every (gate: ...) marker fleet-wide would render an - // ungated PR as clean, the false all-clear this feature exists to avoid. + // Best-effort, like project.List elsewhere in this fleet view: a registry read fault degrades every + // task's gate-run check to silent rather than failing the whole fleet overview over it. projects, projectsErr := project.List(home) if projectsErr != nil { + // Named on stderr all the same - silently dropping every (gate: ...) marker fleet-wide would render + // an ungated PR as clean, the false all-clear this feature exists to avoid. if _, err := fmt.Fprintf(cmd.ErrOrStderr(), "warning: project registry unreadable, gate-run checks skipped: %v\n", projectsErr); err != nil { return nil, nil, err } @@ -387,30 +373,25 @@ func lastReportAt(home, id string) string { // never reported. const reportUnreadable = "unreadable" -// unacknowledged asks state whether this task's terminal report reached a -// watcher, folding a read that fails into the caller's own report-read error: the -// file was readable a moment ago and is not now, which is what that error already -// says, and swallowing it would render an unread completion as an acknowledged -// one. -// -// It takes the state the caller already derived and answers false for anything -// but a terminal one, so the flag can only ever qualify the state this row -// prints. Reading the file a second time is a second snapshot, and a worker -// appending between the two would otherwise put "unacknowledged" next to a -// "working" this command reported in the same breath. +// Asks state whether this task's terminal report reached a watcher. It takes the state the caller already +// derived rather than reading the file a second time: a second snapshot with a worker appending between +// the two would put "unacknowledged" next to a "working" this command reported in the same breath. func unacknowledged(home string, t state.Task, reported state.ReportLine, reportedOK bool, readErr error) (bool, error) { + // A read that fails folds into the caller's own report-read error: the file was readable a moment ago + // and is not now, which is what that error already says, and swallowing it would render an unread + // completion as an acknowledged one. if readErr != nil { return false, readErr } + // False for anything but a terminal state, so the flag can only ever qualify the state this row prints. if !reportedOK || !state.TerminalReport(reported.State) { return false, nil } return state.UnacknowledgedTerminalReport(home, t.ID, state.ReportCursor{Offset: t.ReportOffset, Digest: t.ReportDigest}) } -// buildTaskView reads everything both status views derive from one task, and -// returns the report lines alongside so the detail view's history block and the -// summary line above it can never come from two different reads of the file. +// Reads everything both status views derive from one task, and returns the report lines alongside so the +// detail view's history block and the summary line above it can never come from two reads of the file. func buildTaskView(home string, client *herdr.Client, t state.Task, full bool) (taskView, []state.ReportLine) { agentState := paneAgentStatus(client, t.Herdr.PaneID) lines, readErr := state.ReadReportLines(home, t.ID) @@ -480,14 +461,13 @@ func runStatusSingle(cmd *cobra.Command, home string, client *herdr.Client, id s } v.hold, v.held = hold, held - // Looked up only when the check applies, so a registry this id's detail view - // does not need can never fail the command. When it does apply the failure is - // propagated, not degraded: a single task's own project is the one fact this - // check is about, unlike the fleet view's best-effort lookup across every - // task's project at once. + // Looked up only when the check applies, so a registry this id's detail view does not need can never + // fail the command. reportedDone := v.reportedState == state.ReportDone if gateRunApplies(t, reportedDone) { p, registered, err := project.Find(home, t.Project) + // Propagated, not degraded: a single task's own project is the one fact this check is about, unlike + // the fleet view's best-effort lookup across every task's project at once. if err != nil { return err } @@ -528,13 +508,11 @@ func runStatusSingle(cmd *cobra.Command, home string, client *herdr.Client, id s return doc.Render(cmd.OutOrStdout()) } -// historyBlock is the report tail with the entry the report field already -// shows dropped - repeating it was the core of atqamz/secondhand#65, doubling -// the cost of every terminal report. --full keeps the tail whole. +// The report tail with the entry the report field already shows dropped - repeating it was the core of +// atqamz/secondhand#65, doubling the cost of every terminal report. --full keeps the tail whole. func historyBlock(v taskView, tail []state.ReportLine, full bool) []string { - // Which entry the report field shows. Found rather than assumed last: with - // the unacknowledged flag applied that line is the classified terminal - // report, which the worker may have followed with free text. + // Which entry the report field shows. Found rather than assumed last: with the unacknowledged flag + // applied that line is the classified terminal report, which the worker may have followed with text. reportedIdx := len(tail) - 1 if v.unacked { reportedIdx = -1 @@ -559,9 +537,8 @@ func historyBlock(v taskView, tail []state.ReportLine, full bool) []string { return lines } -// detailHelp names the one command this task's current state calls for, so a -// caller reading the detail view does not have to work out what comes next -// from the state vocabulary. +// Names the one command this task's current state calls for, so a caller reading the detail view does not +// have to work out what comes next from the state vocabulary. func detailHelp(v taskView, full bool) []string { var help []string if !full && strings.Contains(v.reportedLine, "(truncated,") { diff --git a/cmd/status_test.go b/cmd/status_test.go index b9153c6..c42bf08 100644 --- a/cmd/status_test.go +++ b/cmd/status_test.go @@ -17,11 +17,9 @@ import ( "github.com/atqamz/secondhand/internal/store" ) -// writeFakeHerdrPaneStatus fakes "pane get" as a query command per -// internal/herdr/client.go's call() doc comment: a non-null result object on -// success. It always succeeds; the "herdr unreachable" degrade path is -// exercised for real (no fake, empty PATH) by -// TestStatusFleetDegradesToUnknownWhenHerdrUnreachable below. +// Fakes "pane get" as a query command per internal/herdr/client.go's call() doc: a non-null result +// object on success. It always succeeds; the "herdr unreachable" degrade path is exercised for real +// (no fake, empty PATH) by TestStatusFleetDegradesToUnknownWhenHerdrUnreachable below. func writeFakeHerdrPaneStatus(t *testing.T, status string) { t.Helper() bin := t.TempDir() @@ -32,8 +30,8 @@ func writeFakeHerdrPaneStatus(t *testing.T, status string) { t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) } -// fleetRow returns the tasks[] row whose first cell is id, so a test can assert -// on one row's cells without matching the whole document. +// The tasks[] row whose first cell is id, so a test can assert on one row's cells without matching the +// whole document. func fleetRow(t *testing.T, out, id string) string { t.Helper() for _, line := range strings.Split(out, "\n") { @@ -45,7 +43,7 @@ func fleetRow(t *testing.T, out, id string) string { return "" } -// fleetFlags returns the tokens of a default fleet row's trailing flags cell. +// The tokens of a default fleet row's trailing flags cell. func fleetFlags(t *testing.T, out, id string) []string { t.Helper() row := fleetRow(t, out, id) @@ -56,7 +54,7 @@ func fleetFlags(t *testing.T, out, id string) []string { return strings.Fields(cell) } -// mergeFlag is whichever merge token a flags cell carries, or "" for neither. +// Whichever merge token a flags cell carries, or "" for neither. func mergeFlag(flags []string) string { for _, f := range flags { if strings.HasPrefix(f, "merged") { @@ -66,7 +64,7 @@ func mergeFlag(flags []string) string { return "" } -// detailField returns one scalar field of the single-task view, unquoted. +// One scalar field of the single-task view, unquoted. func detailField(t *testing.T, out, name string) string { t.Helper() for _, line := range strings.Split(out, "\n") { @@ -257,8 +255,7 @@ func TestStatusMergeStateCombinationsRenderDistinguishably(t *testing.T) { } } -// TestStatusSingleTaskDetectsGateOpenedPR covers hand status's half of -// atqamz/secondhand#69: a task whose PR a no-mistakes gate opened directly +// hand status's half of atqamz/secondhand#69: a task whose PR a no-mistakes gate opened directly // (bypassing hand pr) still shows the PR, once status looks it up by branch. func TestStatusSingleTaskDetectsGateOpenedPR(t *testing.T) { home, worktree := setupTeardownHome(t) @@ -291,10 +288,9 @@ func TestStatusSingleTaskDetectsGateOpenedPR(t *testing.T) { } } -// A scout task's deliverable is data//report.md, never a PR, so status skips -// the branch lookup for it exactly as checkLandedWork does - the gh fake here -// would answer with a PR, and recording it would pin one onto a task whose -// completion detail never uses it. +// A scout task's deliverable is data//report.md, never a PR, so status skips the branch lookup for +// it exactly as checkLandedWork does - the gh fake here would answer with a PR, and recording it would +// pin one onto a task whose completion detail never uses it. func TestStatusSkipsPRDetectionForScoutTasks(t *testing.T) { home, worktree := setupTeardownHome(t) setupTeardownGateProject(t, home, worktree, "task-1-branch") @@ -457,10 +453,9 @@ func TestStatusFleetFlagsIdleWithTerminalReportInsteadOfUnreported(t *testing.T) } } -// A worker that appends free text after a real report has still reported, so the -// suffix comes from the last line that classified - the same answer hand watch -// reaches about the same quiet pane. The Reported field still shows the raw last -// line, free text included. +// A worker that appends free text after a real report has still reported, so the suffix comes from the +// last line that classified - the same answer hand watch reaches about the same quiet pane. The +// Reported field still shows the raw last line, free text included. func TestStatusFleetKeepsTheReportedFlagAfterATrailingMalformedLine(t *testing.T) { home := t.TempDir() t.Chdir(home) @@ -675,11 +670,9 @@ func TestStatusSingleTaskShowsReportedStateAndHistory(t *testing.T) { } } -// TestStatusSingleTaskDegradesOnAnUnreadableReport holds the detail view to the -// same graceful degradation the fleet view already has: a report file that -// exists but can't be read names the fault and still prints the rest, rather -// than failing the whole command and showing nothing at all. A directory in the -// report file's place is a real EISDIR, not a mocked error. +// Holds the detail view to the same graceful degradation the fleet view already has: a report file +// that exists but can't be read names the fault and still prints the rest, rather than failing the +// whole command and showing nothing. A directory in its place is a real EISDIR, not a mocked error. func TestStatusSingleTaskDegradesOnAnUnreadableReport(t *testing.T) { home := t.TempDir() t.Chdir(home) @@ -1071,10 +1064,9 @@ func TestStatusFleetFlagsInconsistentHold(t *testing.T) { } } -// hand watch writes limit holds, so hand status has to render one as the ordinary fact -// it is. Left out of holdInconsistency it would come out flagged inconsistent, turning -// every routine usage limit into a report that something outside hand corrupted the -// database. +// hand watch writes limit holds, so hand status has to render one as the ordinary fact it is. Left out of +// holdInconsistency it would come out flagged inconsistent, turning every routine usage limit into a +// report that something outside hand corrupted the database. func TestStatusFleetRendersAMachineSetLimitHold(t *testing.T) { home := t.TempDir() t.Chdir(home) @@ -1456,8 +1448,8 @@ func TestStatusFleetEmptyStillShowsHeldBlock(t *testing.T) { } } -// registerNoMistakesProject registers a no-mistakes-mode project and creates its clone directory, so -// gateRunIssue's os.Stat(clonePath) check and its no-mistakes invocation both have something to find. +// Registers a no-mistakes-mode project and creates its clone directory, so gateRunIssue's +// os.Stat(clonePath) check and its no-mistakes invocation both have something to find. func registerNoMistakesProject(t *testing.T, home, name string) { t.Helper() if err := project.Add(home, project.Project{Name: name, URL: "https://example.com/" + name + ".git", Mode: project.ModeNoMistakes}); err != nil { @@ -1468,7 +1460,7 @@ func registerNoMistakesProject(t *testing.T, home, name string) { } } -// writeDoneReport writes a single done report line, so LastReportedState reads the task as reported done. +// Writes a single done report line, so LastReportedState reads the task as reported done. func writeDoneReport(t *testing.T, home, id, note string) { t.Helper() if err := os.WriteFile(state.ReportPath(home, id), []byte("done: "+note+"\n"), 0o644); err != nil { @@ -1578,9 +1570,8 @@ func TestStatusFleetGateRunUnreachableWhenNoMistakesBinaryMissing(t *testing.T) } } -// TestStatusFleetSkipsGateCheckWhenItDoesNotApply covers a scout task with a PR-like field unset and -// no report at all: the check has nothing to say about a task that never shipped, so it must stay -// silent rather than misreport it as an ungated ship. +// A scout task with a PR-like field unset and no report at all: the check has nothing to say about a +// task that never shipped, so it must stay silent rather than misreport it as an ungated ship. func TestStatusFleetSkipsGateCheckWhenItDoesNotApply(t *testing.T) { home := t.TempDir() t.Chdir(home) @@ -1680,8 +1671,8 @@ func TestStatusSingleTaskNoGateLineWhenRunFound(t *testing.T) { } } -// countingNoMistakesPath is fakeNoMistakesPath plus an append to countFile per invocation, so a test -// can assert how many no-mistakes processes one render actually spawned. +// fakeNoMistakesPath plus an append to countFile per invocation, so a test can assert how many +// no-mistakes processes one render actually spawned. func countingNoMistakesPath(t *testing.T, stdout, countFile string) string { t.Helper() bin := t.TempDir() @@ -1692,9 +1683,9 @@ func countingNoMistakesPath(t *testing.T, stdout, countFile string) string { return bin + string(os.PathListSeparator) + os.Getenv("PATH") } -// TestStatusFleetAsksNoMistakesOncePerProject pins the per-clone caching: without it every done ship -// task on one project spawns its own `no-mistakes runs` and re-parses identical output, on the -// command CLAUDE.md makes the first step of every session. +// Pins the per-clone caching: without it every done ship task on one project spawns its own +// `no-mistakes runs` and re-parses identical output, on the command CLAUDE.md makes the first step of +// every session. func TestStatusFleetAsksNoMistakesOncePerProject(t *testing.T) { home := t.TempDir() t.Chdir(home) @@ -1730,8 +1721,8 @@ func TestStatusFleetAsksNoMistakesOncePerProject(t *testing.T) { } } -// writeBrokenRegistry writes a data/projects.md line the registry parser rejects, so project.List -// and project.Find both fail on this home. +// Writes a data/projects.md line the registry parser rejects, so project.List and project.Find both +// fail on this home. func writeBrokenRegistry(t *testing.T, home string) { t.Helper() if err := os.WriteFile(project.RegistryPath(home), []byte("- broken line with no url or mode\n"), 0o644); err != nil { @@ -1768,9 +1759,8 @@ func TestStatusFleetNamesAnUnreadableRegistryOnStderr(t *testing.T) { } } -// TestStatusSingleTaskReadsRegistryOnlyWhenTheGateCheckApplies covers a scout task on a home whose -// registry does not parse: the gate-run check has nothing to say about it, so the detail view must -// not fail over a lookup it never needed. +// A scout task on a home whose registry does not parse: the gate-run check has nothing to say about +// it, so the detail view must not fail over a lookup it never needed. func TestStatusSingleTaskReadsRegistryOnlyWhenTheGateCheckApplies(t *testing.T) { home := t.TempDir() t.Chdir(home) @@ -1815,9 +1805,8 @@ func TestStatusSingleTaskPropagatesAnUnreadableRegistryWhenTheGateCheckApplies(t } } -// TestStatusGateRunUnreachableWhenGateNotInitialized is the uninitialized-gate half of the -// unreachable bucket: no-mistakes still holds that repo's completed runs, so reading its refusal as -// an empty run list would report a genuinely gated PR as never gated. +// The uninitialized-gate half of the unreachable bucket: no-mistakes still holds that repo's completed +// runs, so reading its refusal as an empty run list would report a genuinely gated PR as never gated. func TestStatusGateRunUnreachableWhenGateNotInitialized(t *testing.T) { home := t.TempDir() t.Chdir(home) @@ -1950,11 +1939,9 @@ func TestStatusSingleTaskFlagsATerminalReportNoWatcherConsumed(t *testing.T) { } } -// Free text appended after a real report is expected traffic that must never -// erase it, and enough of it used to push the terminal line out of the detail -// view's 5-line history window - the one view deriving the flag from that -// window instead of the whole file, so it alone called the completion -// acknowledged. +// Free text appended after a real report is expected traffic that must never erase it, and enough of +// it used to push the terminal line out of the detail view's 5-line history window - the one view +// deriving the flag from that window instead of the whole file, so it alone called it acknowledged. func TestStatusSingleTaskFlagsATerminalReportBeyondTheHistoryWindow(t *testing.T) { home := t.TempDir() t.Chdir(home) @@ -2072,11 +2059,9 @@ func TestStatusSingleTaskJSONOmitsUnacknowledgedWhenAcknowledged(t *testing.T) { } } -// A worker whose append lands between the row's own read and the flag's read is -// the one way the two can disagree, and it cannot be staged through the command -// itself - so the guard is exercised where it lives. A --json row saying -// "unacknowledged" next to a "working" it reported in the same breath is -// contradictory on the interface the supervisor reads on every check. +// A worker whose append lands between the row's own read and the flag's read is the one way the two +// can disagree, and it cannot be staged through the command itself - so the guard is exercised where +// it lives. func TestUnacknowledgedAnswersForTheStateTheRowPrints(t *testing.T) { home := t.TempDir() mkFleetDirs(t, home) @@ -2092,6 +2077,8 @@ func TestUnacknowledgedAnswersForTheStateTheRowPrints(t *testing.T) { want bool }{ {name: "row prints the terminal state", reported: state.ReportLine{State: state.ReportDone}, ok: true, want: true}, + // A --json row saying "unacknowledged" next to a "working" it reported in the same breath is + // contradictory on the interface the supervisor reads on every check. {name: "row prints work that supersedes it", reported: state.ReportLine{State: state.ReportWorking}, ok: true}, {name: "row prints nothing classified", reported: state.ReportLine{}}, } @@ -2108,10 +2095,9 @@ func TestUnacknowledgedAnswersForTheStateTheRowPrints(t *testing.T) { } } -// The watcher leaves an unterminated line for its next tick, so a done written -// without a trailing newline has been announced to nobody. Flagging it is the -// whole of atqamz/secondhand#70; skipping it would let the same silent completion -// back in through the newline. +// The watcher leaves an unterminated line for its next tick, so a done written without a trailing +// newline has been announced to nobody. Flagging it is the whole of atqamz/secondhand#70; skipping it +// would let the same silent completion back in through the newline. func TestStatusFleetFlagsATerminalReportWithNoTrailingNewline(t *testing.T) { home := t.TempDir() t.Chdir(home) diff --git a/cmd/statusview.go b/cmd/statusview.go index 83c9def..e95d927 100644 --- a/cmd/statusview.go +++ b/cmd/statusview.go @@ -9,9 +9,8 @@ import ( "github.com/atqamz/secondhand/internal/state" ) -// taskView is one task as both status renderers see it: the durable row plus -// everything derived for display, so the fleet table and the single-task detail -// can never disagree about what a task is doing. +// One task as both status renderers see it: the durable row plus everything derived for display, so the +// fleet table and the single-task detail can never disagree about what a task is doing. type taskView struct { task state.Task agentState string @@ -47,9 +46,8 @@ func unreportedStop(v taskView) bool { return herdr.Status(v.agentState).NotBusy() } -// taskFlags packs the markers the plain-text fleet view used to append to the -// state column as parenthetical suffixes, one token each so a caller can test -// for one without parsing prose. +// Packs the markers the plain-text fleet view used to append to the state column as parenthetical +// suffixes, one token each so a caller can test for one without parsing prose. func taskFlags(v taskView) []string { var flags []string if v.unreadable { @@ -77,9 +75,8 @@ func taskFlags(v taskView) []string { return flags } -// needsAttention is the predicate behind the fleet view's attention aggregate: -// a supervisor reading only the count knows whether any row is asking for -// something without reading the rows. +// The predicate behind the fleet view's attention aggregate: a supervisor reading only the count knows +// whether any row is asking for something without reading the rows. func needsAttention(v taskView) bool { if v.unreadable || v.unacked || v.gateIssue != "" { return true @@ -91,9 +88,8 @@ func needsAttention(v taskView) bool { return unreportedStop(v) } -// taskFields is the vocabulary --fields draws from, for both status views. One -// registry rather than one per view: a field means the same thing wherever it -// is asked for. +// The vocabulary --fields draws from, for both status views. One registry rather than one per view: a +// field means the same thing wherever it is asked for. var taskFields = []axi.Column[taskView]{ {Name: "id", Value: func(v taskView) string { return v.task.ID }}, {Name: "project", Value: func(v taskView) string { return v.task.Project }}, diff --git a/cmd/teardown.go b/cmd/teardown.go index b7257bf..d580c55 100644 --- a/cmd/teardown.go +++ b/cmd/teardown.go @@ -68,41 +68,39 @@ func newTeardownCmd() *cobra.Command { } } - // treehouse refuses to clean a dirty worktree without --force, and - // nothing is left to answer its prompt here, so dirt this command - // already judged discardable has to be returned forcibly or the - // slot goes back to the pool still dirty. + // treehouse refuses to clean a dirty worktree without --force, and nothing is left to answer + // its prompt here, so dirt this command already judged discardable has to be returned + // forcibly or the slot goes back to the pool still dirty. if err := worktree.Return(t.Worktree, force || dirtWasSafe); err != nil { return err } + // Everything the record claims (landed work, a returned worktree) is already true by this + // line, --force or not, so a fault after it lands cannot make the record inaccurate, only + // late to remove its source. record := completionFor(t, force) record.TornDownAt = time.Now().UTC().Format(time.RFC3339) - // Recorded before state.Delete, not after: the record is derived from t, which - // state.Delete would remove out from under us. Failing here leaves the task row - // untouched, so the whole command is simply retryable and no completion is lost. - // A failure the other way around - after the record lands but before teardown - // actually finishes - cannot make the record inaccurate, only late to remove its - // source: everything the record claims (landed work, a returned worktree) is - // already true by this line, --force or not, so the completion is durable either - // way. state.Delete failing leaves the task row in place, so a retry replays the - // whole command and appends a second, identical record - a harmless duplicate this - // trades for never losing a completion. + // Recorded before state.Delete, not after: the record is derived from t, which state.Delete + // would remove out from under us. Failing here leaves the task row untouched, so the whole + // command is simply retryable and no completion is lost. if err := completion.Append(home, record); err != nil { return fmt.Errorf("record completion: %w", err) } + // Failing here leaves the task row in place, so a retry replays the whole command and + // appends a second, identical record - a harmless duplicate this trades for never losing a + // completion. if err := state.Delete(home, id); err != nil { return asPrecondition(err) } - // A hold outlives the task row it was set on, which is what an operator - // hold is for. A limit hold is the opposite: nothing is left to resume - // and no watcher will ever clear it, so left behind it would refuse - // `hand spawn` on this id forever. A warning rather than a failure - the - // teardown itself is done, and re-running it cannot undo the delete. + // A hold outlives the task row it was set on, which is what an operator hold is for. A limit hold + // is the opposite: nothing is left to resume and no watcher will ever clear it, so left behind it + // would refuse `hand spawn` on this id forever. if err := state.ClearHoldIfKind(home, id, state.HoldKindLimit); err != nil { + // A warning rather than a failure: the teardown itself is done, and re-running it cannot undo + // the delete. if _, printErr := fmt.Fprintf(cmd.ErrOrStderr(), "warning: clear usage-limit hold failed: %v\n", err); printErr != nil { return printErr } @@ -127,16 +125,16 @@ func newTeardownCmd() *cobra.Command { func completionFor(t state.Task, forced bool) completion.Record { c := completion.Record{ID: t.ID, Project: t.Project, Kind: t.Kind} + // The delivered case sits ahead of the merge cases below only while no merge is on the row: a + // delivery that then genuinely landed has the stronger fact to record, so an observed or executed + // merge outranks the mark. switch { case forced: c.Outcome = "torn-down" c.Detail = "forced (landed-work checks skipped)" - // Ahead of the merge cases below only while no merge is on the row. A task - // whose landing was never ours to decide has to stay distinguishable from a - // merged one in the permanent record, or the fleet's history claims upstream - // merges that never happened (atqamz/secondhand#78) - but a delivery that then - // genuinely landed has the stronger fact to record, so an observed or executed - // merge outranks the mark. + // A task whose landing was never ours to decide has to stay distinguishable from a merged one in + // the permanent record, or the fleet's history claims upstream merges that never happened + // (atqamz/secondhand#78). case t.DeliveredAt != "" && !t.MergeExecuted && !t.MergeAnnounced: c.Outcome = "delivered" c.Detail = t.DeliveredReason @@ -156,10 +154,9 @@ func completionFor(t state.Task, forced bool) completion.Record { return c } -// checkLandedWork reports whether the task's work is landed, and whether it got -// there past dirt it judged safe to discard - the caller has to force the -// worktree return in that case, since treehouse will not clean a dirty worktree -// on its own. +// Reports whether the task's work is landed, and whether it got there past dirt it judged safe to +// discard - the caller has to force the worktree return in that case, since treehouse will not clean a +// dirty worktree on its own. func checkLandedWork(ctx context.Context, home string, t state.Task) (state.Task, bool, error) { if t.Kind == state.KindScout { reportPath := filepath.Join("data", t.ID, "report.md") @@ -181,17 +178,13 @@ func checkLandedWork(ctx context.Context, home string, t state.Task) (state.Task dirtWasSafe = true } - // Every check below asks "did this land", which for a contribution offered to - // someone else's repo is a question hand cannot answer and the fleet does not - // decide. A recorded delivery answers the question teardown actually needs - // answered - is the work out of this worktree and accounted for - so it is - // terminal here without --force, and the completion record says delivered - // rather than merged (atqamz/secondhand#78). - // - // Deliberately after the dirt check, and after the scout report check above: - // --force keeps its one meaning of discarding work nobody delivered, so - // uncommitted changes still refuse, and a scout with no report on disk has - // delivered nothing whatever its row says. + // Every check below asks "did this land", which for a contribution offered to someone else's repo + // hand cannot answer and the fleet does not decide. A recorded delivery answers what teardown needs + // instead - the work is out of this worktree and accounted for - so it is terminal here. + + // Terminal without --force too, and recorded as delivered rather than merged + // (atqamz/secondhand#78). Placed after the dirt check and the scout report check deliberately: + // --force keeps its one meaning of discarding work nobody delivered, so both of those still refuse. if t.DeliveredAt != "" { return t, dirtWasSafe, nil } @@ -212,24 +205,21 @@ func checkLandedWork(ctx context.Context, home string, t state.Task) (state.Task return t, dirtWasSafe, nil } - // A gate-opened PR bypasses hand pr entirely (issue #69), so t.PR can still - // be empty for landed work; detect it here rather than only refusing on it, - // so the merged check below reads the same PR state hand pr would have - // recorded. Detection failing (no clone on disk yet, gh unreachable, ...) - // is not this command's failure to report: it falls through to the same - // "no PR recorded" refusal below that a project with no detection at all - // would have gotten. - // - // An ambiguous branch is a different failure and must not fall through the - // same way: "no PR recorded" reads as unlanded, but ambiguous means unknown, - // and picking either meaning here for the operator is the guess atqamz/secondhand#77 - // exists to remove. + // A gate-opened PR bypasses hand pr entirely (atqamz/secondhand#69), so t.PR can still be empty + // for landed work; detect it here rather than only refusing on it, so the merged check below + // reads the same PR state hand pr would have recorded. if exists { detected, err := detectPR(ctx, home, t, proj) var ambiguous *ghutil.AmbiguousPRError + // An ambiguous branch is a different failure and must not fall through the same way: "no PR + // recorded" reads as unlanded, but ambiguous means unknown, and picking either meaning here + // for the operator is the guess atqamz/secondhand#77 exists to remove. if errors.As(err, &ambiguous) { return t, false, &ExitError{Err: fmt.Errorf("PR for %s is ambiguous, refusing to guess: %w", t.ID, ambiguous), Code: 3} } + // Detection failing (no clone on disk yet, gh unreachable, ...) is not this command's failure + // to report: it falls through to the same "no PR recorded" refusal below that a project with + // no detection at all would have gotten. if err == nil { t = detected } @@ -237,24 +227,22 @@ func checkLandedWork(ctx context.Context, home string, t state.Task) (state.Task if t.PR == "" { // kind is the one field spawn records that nothing can correct afterwards - // (atqamz/secondhand#129), so a scout spawned without --scout arrives here as - // a ship row and refuses on a PR it was never going to open. The guard reads - // the work rather than the record for that case: a report deliverable on disk - // and a branch carrying no commits of its own is a completed scout whatever - // the row says, and it is recorded as one. - // - // Here rather than earlier so it can only decide the case nothing else claims: - // a delivery, a local-only merge, and a gate-opened PR all return above it, so - // reaching this line means no PR exists to shadow. Merge evidence on the row - // excludes the path outright - work hand merged or watched merge landed as a - // merge, and the permanent record has to say so (atqamz/secondhand#78). - // - // Narrow on purpose. A ship task whose PR was never opened still has its - // commits, so it still refuses - the half of this guard that is load-bearing. + // (atqamz/secondhand#129), so a scout spawned without --scout arrives here as a ship row and + // refuses on a PR it was never going to open. The guard reads the work rather than the record. + + // Here rather than earlier so it can only decide the case nothing else claims: a delivery, a + // local-only merge, and a gate-opened PR all return above it, so reaching this line means no PR + // exists to shadow. + + // A report deliverable on disk and a branch carrying no commits of its own is a completed scout + // whatever the row says, and is recorded as one. Merge evidence excludes the path outright - work + // hand merged or watched merge landed as a merge, and the record has to say so (atqamz/secondhand#78). if !t.MergeExecuted && !t.MergeAnnounced && isCompletedScout(home, t) { t.Kind = state.KindScout return t, dirtWasSafe, nil } + // Narrow on purpose: a ship task whose PR was never opened still has its commits, so it still + // refuses - the half of this guard that is load-bearing. return t, false, &ExitError{Err: fmt.Errorf("no PR recorded for %s and project is not local-only: work may not be landed", t.ID), Code: 3} } } @@ -269,18 +257,15 @@ func checkLandedWork(ctx context.Context, home string, t state.Task) (state.Task return t, dirtWasSafe, nil } -// isCompletedScout reports whether a task's work is a delivered scout report and -// nothing else: the report exists under data// and the worktree's branch adds -// no commit to the local default branch. Both halves are required - a report alone -// says nothing about code sitting unlanded on the branch beside it. -// -// Resolution failures fail closed, and the branch comparison is local-only like -// dirtIsSafeToDiscard's: a stale local ref only misses a real case, it never -// accepts an unlanded one. +// Reports whether a task's work is a delivered scout report and nothing else: the report exists under +// data// and the worktree's branch adds no commit to the local default branch. Both halves are +// required - a report alone says nothing about code sitting unlanded on the branch beside it. func isCompletedScout(home string, t state.Task) bool { if _, err := os.Stat(filepath.Join(home, "data", t.ID, "report.md")); err != nil { return false } + // Resolution failures fail closed, and the branch comparison is local-only like dirtIsSafeToDiscard's: + // a stale local ref only misses a real case, it never accepts an unlanded one. baseRef, err := localDefaultBranchRef(t.Worktree) if err != nil { return false @@ -312,9 +297,9 @@ func hasUncommittedChanges(worktreePath string) (bool, error) { return status != "", nil } -// maxDirtStatusLines bounds the refusal's git status dump (atqamz/secondhand#65 -// is the same lesson for report rendering): an unbounded dump into a session is its -// own problem, so this prints the first N entries and a count of the rest. +// Bounds the refusal's git status dump (atqamz/secondhand#65 is the same lesson for report rendering): +// an unbounded dump into a session is its own problem, so this prints the first N entries and a count +// of the rest. const maxDirtStatusLines = 20 func capStatusLines(status string) string { @@ -330,31 +315,16 @@ func capStatusLines(status string) string { return strings.Join(lines[:maxDirtStatusLines], "\n") + fmt.Sprintf("\n...and %d more", rest) } -// dirtIsSafeToDiscard reports whether every uncommitted change in status - the -// worktree's `git status --porcelain` output - is a tracked modification whose -// content already matches the local default branch's tip byte-for-byte: the -// no-mistakes gate re-editing a file to exactly the content its own merged fix -// already carries (atqamz/secondhand#79). Discarding dirt like that on teardown -// loses nothing. -// -// Each porcelain line reports two layers, index and working tree, and a change at -// either one is content that teardown would throw away, so each layer that reports -// a change is compared against the base on its own: an "MM" path whose working copy -// matches the base still hides a third, differing version staged in the index. -// -// Untracked files are never safe: there is nothing in the base to compare them -// against, so their mere presence fails this check. Checking only that the path -// exists in the base, or comparing paths without comparing content, both pass -// cases that lose data (a same-named file with different content, or a path -// that only coincidentally matches); this compares actual bytes for that reason. -// -// Every failure to resolve, read, or parse fails closed - the caller gets the -// ordinary refusal, never a discard on unverified dirt. Resolution is local-only, -// no fetch: a stale local ref just means a real safe case is missed. +// Reports whether every uncommitted change in status - the worktree's `git status --porcelain` output - +// is a tracked modification whose content already matches the local default branch's tip, byte for +// byte: the gate re-editing a file to what its own merged fix carries (atqamz/secondhand#79). func dirtIsSafeToDiscard(worktreePath, status string) bool { if status == "" { return true } + // Every failure to resolve, read, or parse below fails closed - the caller gets the ordinary + // refusal, never a discard on unverified dirt. Resolution is local-only, no fetch: a stale local ref + // just means a real safe case is missed. baseRef, err := localDefaultBranchRef(worktreePath) if err != nil { return false @@ -365,6 +335,8 @@ func dirtIsSafeToDiscard(worktreePath, status string) bool { return false } indexState, workingState, path := line[0], line[1], line[3:] + // Untracked files are never safe: there is nothing in the base to compare them against, so their + // mere presence fails this check. if indexState == '?' || workingState == '?' { return false } @@ -372,10 +344,16 @@ func dirtIsSafeToDiscard(worktreePath, status string) bool { path = path[idx+len(" -> "):] } + // Actual bytes, not the path: checking only that the path exists in the base, or comparing paths + // alone, both pass cases that lose data - a same-named file with different content, or a path + // that only coincidentally matches. base, err := gitShowBlob(worktreePath, baseRef, path) if err != nil { return false } + // Each porcelain line reports two layers, and a change at either is content teardown would throw + // away, so each is compared against the base on its own: an "MM" path whose working copy matches + // the base still hides a third, differing version staged in the index. if indexState != ' ' { staged, err := gitShowBlob(worktreePath, "", path) if err != nil || !bytes.Equal(staged, base) { @@ -389,12 +367,12 @@ func dirtIsSafeToDiscard(worktreePath, status string) bool { } } } + // Discarding dirt of this shape on teardown loses nothing. return true } -// localDefaultBranchRef resolves the worktree's local knowledge of the default -// branch without touching the network: a real treehouse worktree shares its -// refs with the project clone it was leased from, so a prior fetch there +// Resolves the worktree's local knowledge of the default branch without touching the network: a real +// treehouse worktree shares its refs with the project clone it was leased from, so a prior fetch there // already left refs/remotes/origin/HEAD in place for it to read directly. func localDefaultBranchRef(worktreePath string) (string, error) { c := exec.Command("git", "symbolic-ref", "--short", "-q", "refs/remotes/origin/HEAD") @@ -422,8 +400,8 @@ func localDefaultBranchRef(worktreePath string) (string, error) { return "", fmt.Errorf("cannot resolve a local default branch ref") } -// gitShowBlob reads path's content at ref. An empty ref reads the index's -// stage-0 blob, which is what `git show :path` means. +// Reads path's content at ref. An empty ref reads the index's stage-0 blob, which is what +// `git show :path` means. func gitShowBlob(worktreePath, ref, path string) ([]byte, error) { c := exec.Command("git", "show", ref+":"+path) c.Dir = worktreePath @@ -502,14 +480,9 @@ func currentBranch(worktreePath string) (string, error) { return strings.TrimSpace(string(out)), nil } -// closeTaskTab closes the task's tab, or the whole workspace when this was its last -// tab - herdr closes the workspace either way, so this says so rather than leaving -// it to a side effect. internal/faketool/FIDELITY.md records that. -// -// A tab that is no longer listed is already closed, which is this step's goal, so -// it is success and not an error. Teardown removes several resources in sequence -// and any later step can fail, so a rerun must not act on the first run's work: with -// one tab left it would read as the sole-tab case and close another task's workspace. +// Closes the task's tab, or the whole workspace when this was its last tab. herdr closes the +// workspace either way, so this says so rather than leaving it to a side effect +// (internal/faketool/FIDELITY.md). func closeTaskTab(client *herdr.Client, workspaceID, tabID string) error { tabs, err := client.TabList(workspaceID) if err != nil { @@ -522,9 +495,14 @@ func closeTaskTab(client *herdr.Client, workspaceID, tabID string) error { break } } + // A tab that is no longer listed is already closed, which is this step's goal, so it is success and + // not an error: teardown removes several resources in sequence and any later step can fail, so the + // whole command has to be runnable again without tripping over the work the first run already did. if !found { return nil } + // Reached only while the tab is still listed, so a rerun after the first run closed it never reads + // the one tab left as this workspace's last and closes another task's workspace. if len(tabs) == 1 { return client.WorkspaceClose(workspaceID) } diff --git a/cmd/teardown_test.go b/cmd/teardown_test.go index 13aeb5b..3dd2c92 100644 --- a/cmd/teardown_test.go +++ b/cmd/teardown_test.go @@ -63,10 +63,9 @@ func writeFakeGHPRState(t *testing.T, prState string) { }}.Install(t, faketool.Bin(t)) } -// The two tools teardown shells out to, both from internal/faketool so each keeps -// the state its own commands change: the returned worktree's slot stops being -// leasable and the closed tab stops being listed. worktree need not exist yet, -// only be the path the pool will be asked for. +// The two tools teardown shells out to, both from internal/faketool so each keeps the state its own +// commands change: the returned worktree's slot stops being leasable and the closed tab stops being +// listed. worktree need not exist yet, only be the path the pool will be asked for. func writeFakeTreehouseReturn(t *testing.T, worktree string) { t.Helper() bin := faketool.Bin(t) @@ -93,18 +92,16 @@ func readInvocations(t *testing.T, worktree string) []string { return strings.Split(strings.TrimSpace(string(data)), "\n") } -// ghFakePR is one PR on the task branch, in the project's own repo. +// One PR on the task branch, in the project's own repo. type ghFakePR struct { Number int URL string State string } -// The PRs a gate-opened-PR detection finds: `gh pr list --repo --head -// ` (FindPRByBranch) then `gh pr view --json state` -// (project.ValidatePR's existence check, then checkLandedWork's own merged check). -// Several of them is what exercises FindPRByBranch's preference-tier rule -// (atqamz/secondhand#77) rather than only its single-result path. +// The two calls gate-opened-PR detection makes: `gh pr list --repo --head ` +// (FindPRByBranch) then `gh pr view --json state` (project.ValidatePR, then checkLandedWork). +// Several PRs exercise FindPRByBranch's preference tier (atqamz/secondhand#77), not its single result. func writeFakeGHPRListAndView(t *testing.T, prs ...ghFakePR) { t.Helper() g := faketool.GH{} @@ -117,18 +114,17 @@ func writeFakeGHPRListAndView(t *testing.T, prs ...ghFakePR) { g.Install(t, faketool.Bin(t)) } -// setupTeardownGateProject registers a non-local-only project whose clone has a -// GitHub origin remote (RepoSlug reads it) and re-points worktree's checked-out -// branch to branch, so FindPRByBranch's --head argument matches it. +// Registers a non-local-only project whose clone has a GitHub origin remote (RepoSlug reads it) and +// re-points worktree's checked-out branch to branch, so FindPRByBranch's --head argument matches it. func setupTeardownGateProject(t *testing.T, home, worktree, branch string) { t.Helper() runGitIn(t, worktree, "checkout", "-q", "-b", branch) registerGateProject(t, home) } -// registerGateProject is setupTeardownGateProject's clone-and-register half only, -// for tests that need to control the worktree's branch checkout themselves (e.g. -// to leave a diverging commit on main before switching to the task branch). +// setupTeardownGateProject's clone-and-register half only, for tests that need to control the +// worktree's branch checkout themselves (to leave a diverging commit on main before switching to the +// task branch, say). func registerGateProject(t *testing.T, home string) { t.Helper() clonePath := filepath.Join(home, "projects", "myproj") @@ -140,10 +136,9 @@ func registerGateProject(t *testing.T, home string) { } } -// writeFakeGHPRListAndView for a fork project: the PR lives on the upstream while -// the branch lives in headRepo, so a search of the project's own repo comes back -// empty and the fork filter has a headRepository to read. Without a fake that -// narrows on --repo no test can express the atqamz/secondhand#134 shape at all. +// The fork variant: the PR lives on the upstream while the branch lives in headRepo, so a search of +// the project's own repo comes back empty and the fork filter has a headRepository to read. Without a +// fake that narrows on --repo no test can express the atqamz/secondhand#134 shape at all. func writeFakeGHForkPRListAndView(t *testing.T, upstream, headRepo string, pr ghFakePR) { t.Helper() faketool.GH{PRs: []faketool.GHPR{{ @@ -152,10 +147,9 @@ func writeFakeGHForkPRListAndView(t *testing.T, upstream, headRepo string, pr gh }}}.Install(t, faketool.Bin(t)) } -// TestTeardownDetectsGateOpenedPRonDeclaredUpstream is the atqamz/secondhand#134 -// regression: a fork project's gate opens its PR on the declared upstream, so -// detection that searches the project's own repo alone finds nothing and teardown -// refuses landed work as unlanded. +// The atqamz/secondhand#134 regression: a fork project's gate opens its PR on the declared upstream, +// so detection that searches the project's own repo alone finds nothing and teardown refuses landed +// work as unlanded. func TestTeardownDetectsGateOpenedPRonDeclaredUpstream(t *testing.T) { home, worktree := setupTeardownHome(t) setupTeardownGateProject(t, home, worktree, "task-1-branch") @@ -180,11 +174,9 @@ func TestTeardownDetectsGateOpenedPRonDeclaredUpstream(t *testing.T) { } } -// TestTeardownDetectsPRWithUpstreamDeclaredAsOwnRepoInOtherCasing covers the -// self-declared upstream: GitHub slugs are case-insensitive, so an upstream naming -// the project's own repo in different casing must not be searched as a second repo - -// every PR would come back twice and refuse as its own same-tier duplicate, the -// failure this detection exists to remove. +// Covers the self-declared upstream: GitHub slugs are case-insensitive, so an upstream naming the +// project's own repo in different casing must not be searched as a second repo - every PR would come +// back twice and refuse as its own same-tier duplicate, the failure this detection exists to remove. func TestTeardownDetectsPRWithUpstreamDeclaredAsOwnRepoInOtherCasing(t *testing.T) { home, worktree := setupTeardownHome(t) setupTeardownGateProject(t, home, worktree, "task-1-branch") @@ -209,11 +201,9 @@ func TestTeardownDetectsPRWithUpstreamDeclaredAsOwnRepoInOtherCasing(t *testing. } } -// TestTeardownDetectsAndTearsDownGateOpenedMergedPR is the first of the two -// regression cases atqamz/secondhand#69 requires: a no-mistakes gate's own `pr` -// step opens a PR directly, bypassing `hand pr`, so t.PR is empty even though the -// PR is merged and the work is landed. Teardown must detect it by branch and -// tear down without --force, not just refuse until someone passes it. +// First of the two regression cases atqamz/secondhand#69 requires: a no-mistakes gate's own `pr` step +// opens a PR directly, bypassing `hand pr`, so t.PR is empty even though the PR is merged and the work +// landed. Teardown must detect it by branch and tear down without --force, not refuse until forced. func TestTeardownDetectsAndTearsDownGateOpenedMergedPR(t *testing.T) { home, worktree := setupTeardownHome(t) setupTeardownGateProject(t, home, worktree, "task-1-branch") @@ -234,9 +224,8 @@ func TestTeardownDetectsAndTearsDownGateOpenedMergedPR(t *testing.T) { } } -// TestTeardownRefusesGateOpenedClosedUnmergedPR is the second regression case: -// a detected PR that is closed without merging is not landed work, and the guard -// must still refuse it exactly as it would a `hand pr`-recorded one. +// The second regression case: a detected PR that is closed without merging is not landed work, and the +// guard must still refuse it exactly as it would a `hand pr`-recorded one. func TestTeardownRefusesGateOpenedClosedUnmergedPR(t *testing.T) { home, worktree := setupTeardownHome(t) setupTeardownGateProject(t, home, worktree, "task-1-branch") @@ -267,11 +256,9 @@ func TestTeardownRefusesGateOpenedClosedUnmergedPR(t *testing.T) { } } -// TestTeardownTearsDownWhenBranchHasMergedAndClosedUnmergedPR is the -// atqamz/secondhand#77 landed case: a branch carrying a closed-unmerged PR -// alongside a merged one (a duplicate opened by mistake, say) must tear down -// on the merged PR, not fall to an arbitrary pick that could land on the -// unmerged one instead. +// The atqamz/secondhand#77 landed case: a branch carrying a closed-unmerged PR alongside a merged one +// (a duplicate opened by mistake, say) must tear down on the merged PR, not fall to an arbitrary pick +// that could land on the unmerged one instead. func TestTeardownTearsDownWhenBranchHasMergedAndClosedUnmergedPR(t *testing.T) { home, worktree := setupTeardownHome(t) setupTeardownGateProject(t, home, worktree, "task-1-branch") @@ -294,10 +281,9 @@ func TestTeardownTearsDownWhenBranchHasMergedAndClosedUnmergedPR(t *testing.T) { } } -// TestTeardownRefusesAmbiguousBranch is atqamz/secondhand#77's refusal case: -// two merged PRs on the same branch do not resolve to a winner, and teardown -// must refuse naming both rather than guess which one to trust - the exact -// guess that could wave through unlanded work. +// atqamz/secondhand#77's refusal case: two merged PRs on the same branch do not resolve to a winner, +// and teardown must refuse naming both rather than guess which one to trust - the exact guess that +// could wave through unlanded work. func TestTeardownRefusesAmbiguousBranch(t *testing.T) { home, worktree := setupTeardownHome(t) setupTeardownGateProject(t, home, worktree, "task-1-branch") @@ -334,9 +320,8 @@ func TestTeardownRefusesAmbiguousBranch(t *testing.T) { } } -// TestTeardownRefusesMergedAndOpenPR proves a branch carrying both a merged PR -// and a still-open one refuses instead of tearing down on the merged PR: the -// open PR is live evidence the branch may carry unlanded work. +// A branch carrying both a merged PR and a still-open one refuses instead of tearing down on the +// merged PR: the open PR is live evidence the branch may carry unlanded work. func TestTeardownRefusesMergedAndOpenPR(t *testing.T) { home, worktree := setupTeardownHome(t) setupTeardownGateProject(t, home, worktree, "task-1-branch") @@ -377,7 +362,7 @@ func setupTeardownHome(t *testing.T) (home, worktree string) { return home, worktree } -// writeScoutReport puts the scout deliverable on disk for id. +// Puts the scout deliverable on disk for id. func writeScoutReport(t *testing.T, home, id string) { t.Helper() if err := os.MkdirAll(filepath.Join(home, "data", id), 0o755); err != nil { @@ -442,14 +427,9 @@ func TestTeardownShipSucceedsWhenPRMerged(t *testing.T) { } } -// TestTeardownRetriesAfterReportRemovalFails proves teardown survives a fault in -// its last step, which takes both halves of "retryable". state.Delete's removal -// order (report channel before the task row) leaves the task row untouched, so -// there is something left to retry; and the retry then re-runs the cleanup steps -// the first call already completed, which have to treat an already-closed tab and -// an already-returned worktree as success. Reverting either half fails this test: -// the ordering leaves nothing to retry, the idempotency leaves the retry dying on -// a tab herdr no longer lists. +// Teardown must survive a fault in its last step, which takes both halves of "retryable". Reverting +// either half fails this test: the ordering leaves nothing to retry, the idempotency leaves the retry +// dying on a tab herdr no longer lists. func TestTeardownRetriesAfterReportRemovalFails(t *testing.T) { home, worktree := setupTeardownHome(t) writeFakeGHPRState(t, "MERGED") @@ -473,6 +453,8 @@ func TestTeardownRetriesAfterReportRemovalFails(t *testing.T) { if err == nil || !strings.Contains(err.Error(), "remove report channel") { t.Fatalf("got err %v, want a remove report channel failure", err) } + // state.Delete's removal order - report channel before the task row - leaves the task row untouched, + // so there is something left to retry at all. if exists, err := state.Exists(home, "task-1"); err != nil || !exists { t.Fatalf("state gone after failed teardown, want it retryable: %v %v", exists, err) } @@ -481,6 +463,8 @@ func TestTeardownRetriesAfterReportRemovalFails(t *testing.T) { t.Fatal(err) } + // The retry re-runs the cleanup steps the first call already completed, which have to treat an + // already-closed tab and an already-returned worktree as success. cmd = newTeardownCmd() cmd.SetArgs([]string{"task-1"}) if err := cmd.Execute(); err != nil { @@ -537,11 +521,9 @@ func TestTeardownRecordsCompletionBeforeStateRemoval(t *testing.T) { } } -// TestTeardownCompletionAppendFailureLeavesStateIntact proves the ordering in -// cmd/teardown.go survives a fault in completion.Append the same way -// TestTeardownRetriesAfterReportRemovalFails proves it for state.Delete's report -// removal: state.Delete never runs, so the task stays retryable, and the retry -// succeeds without leaving a second, duplicate record behind a failed first line. +// The ordering in cmd/teardown.go must survive a fault in completion.Append the same way +// TestTeardownRetriesAfterReportRemovalFails covers state.Delete's report removal: state.Delete never +// runs, so the task stays retryable, and the retry leaves no duplicate record behind a failed line. func TestTeardownCompletionAppendFailureLeavesStateIntact(t *testing.T) { home, worktree := setupTeardownHome(t) writeFakeGHPRState(t, "MERGED") @@ -780,11 +762,9 @@ func TestTeardownScoutSucceedsWhenReportPresent(t *testing.T) { } } -// TestTeardownAcceptsAShipRowThatDeliveredAScoutReport is atqamz/secondhand#129: -// a scout spawned without --scout is recorded as a ship task and nothing can -// correct the record, so its report-and-no-PR shape hits the landed-work refusal -// and --force plus a respawn was the only way out. Teardown reads the work -// instead, and the permanent record says scout. +// atqamz/secondhand#129: a scout spawned without --scout is recorded as a ship task and nothing can +// correct the record, so its report-and-no-PR shape hits the landed-work refusal and --force plus a +// respawn was the only way out. Teardown reads the work instead, and the permanent record says scout. func TestTeardownAcceptsAShipRowThatDeliveredAScoutReport(t *testing.T) { home, worktree := setupTeardownHome(t) runGitIn(t, worktree, "checkout", "-q", "-b", "task-1-branch") @@ -813,11 +793,9 @@ func TestTeardownAcceptsAShipRowThatDeliveredAScoutReport(t *testing.T) { } } -// TestTeardownStillRefusesAShipTaskWhosePRWasNeverOpened is the half of -// atqamz/secondhand#129's fix that matters: the scout-deliverable path must not -// become "no PR and some file exists". This task carries a report next to a commit -// nobody landed, and the commit is what keeps the refusal. A guard that only -// checked for the report would accept it and throw the commit away. +// The half of atqamz/secondhand#129's fix that matters: the scout-deliverable path must not become "no PR +// and some file exists". This task carries a report next to a commit nobody landed, and the commit is +// what keeps the refusal. A guard that only checked for the report would throw the commit away. func TestTeardownStillRefusesAShipTaskWhosePRWasNeverOpened(t *testing.T) { home, worktree := setupTeardownHome(t) runGitIn(t, worktree, "checkout", "-q", "-b", "task-1-branch") @@ -845,11 +823,9 @@ func TestTeardownStillRefusesAShipTaskWhosePRWasNeverOpened(t *testing.T) { } } -// A promoted scout keeps its report on disk while its row turns into a ship, so a -// task that then merged locally has every shape the scout-deliverable path reads: -// report present, no PR, and a branch fast-forwarded into the default branch, which -// adds no commit of its own. It landed as a merge and the permanent record has to -// say merged, not scout - the inverse of the atqamz/secondhand#78 accuracy rule. +// A promoted scout keeps its report on disk while its row turns into a ship, so a task that then merged +// locally has every shape the scout-deliverable path reads: report present, no PR, and a branch that was +// fast-forwarded in and so adds no commit. It landed as a merge - the inverse of atqamz/secondhand#78. func TestTeardownRecordsMergedWhenALocallyMergedShipRowKeptItsScoutReport(t *testing.T) { home := t.TempDir() t.Chdir(home) @@ -890,10 +866,9 @@ func TestTeardownRecordsMergedWhenALocallyMergedShipRowKeptItsScoutReport(t *tes } } -// Merge evidence excludes the scout-deliverable path on its own, wherever the row -// came from: a task hand merged is not a report, so it never reaches the completion -// store as one. Nothing else can confirm this landing without a PR, so the ordinary -// refusal stands and the row survives for the operator to resolve. +// Merge evidence excludes the scout-deliverable path on its own, wherever the row came from: a task hand +// merged is not a report, so it never reaches the completion store as one. Nothing else can confirm this +// landing without a PR, so the ordinary refusal stands and the row survives for the operator to resolve. func TestTeardownStillRefusesAMergedShipRowWithNoPRToConfirm(t *testing.T) { home, worktree := setupTeardownHome(t) runGitIn(t, worktree, "checkout", "-q", "-b", "task-1-branch") @@ -917,12 +892,9 @@ func TestTeardownStillRefusesAMergedShipRowWithNoPRToConfirm(t *testing.T) { } } -// The central case for atqamz/secondhand#78: a contribution offered to a repo -// this fleet does not control. Landing it is the upstream maintainer's decision, -// so the PR stays open indefinitely, and the fake gh here reports exactly that. -// Teardown has to accept it without --force, and the permanent record has to say -// delivered rather than merged - claiming a merge nobody made is the failure this -// state exists to prevent. +// The central case for atqamz/secondhand#78: a contribution offered to a repo this fleet does not control. +// Landing it is the upstream maintainer's decision, so the PR stays open indefinitely and the fake gh +// reports that. Teardown accepts it without --force and records delivered - a merge nobody made is the risk. func TestTeardownAcceptsDeliveredWorkWithAnOpenPRWithoutForce(t *testing.T) { home, worktree := setupTeardownHome(t) writeFakeGHPRState(t, "OPEN") @@ -950,6 +922,7 @@ func TestTeardownAcceptsDeliveredWorkWithAnOpenPRWithoutForce(t *testing.T) { if len(records) != 1 { t.Fatalf("completions = %+v, want exactly one", records) } + // Claiming a merge nobody made is the failure the delivered state exists to prevent. if records[0].Outcome != "delivered" { t.Fatalf("outcome = %q, want delivered and never merged", records[0].Outcome) } @@ -958,10 +931,9 @@ func TestTeardownAcceptsDeliveredWorkWithAnOpenPRWithoutForce(t *testing.T) { } } -// A task filed as a ship whose deliverable was a report, no branch and no commit -// behind it - the shape a misfiled kind produces (atqamz/secondhand#129). The -// delivered state is keyed off the delivery, not off Kind, so this tears down -// cleanly without anyone having to correct the kind first. +// A task filed as a ship whose deliverable was a report, no branch and no commit behind it - the shape +// a misfiled kind produces (atqamz/secondhand#129). The delivered state keys off the delivery, not off +// Kind, so this tears down cleanly without anyone having to correct the kind first. func TestTeardownAcceptsDeliveredWorkWithNoPRRegardlessOfKind(t *testing.T) { home, worktree := setupTeardownHome(t) if err := state.Write(home, state.Task{ID: "task-1", Kind: state.KindShip, Worktree: worktree, Project: "myproj", @@ -1119,11 +1091,9 @@ func TestTeardownWaitsForProjectLockBeforeClosingResources(t *testing.T) { marker := filepath.Join(t.TempDir(), "herdr-called") bin := t.TempDir() - // This and the herdr fakes further down this file all return a non-null - // object result for "tab list"/"tab close"/"workspace close", which is - // exactly what call() requires for success (client.go); these three are - // query commands, not the void pane commands callVoid documents, so there - // is no exit-code-vs-envelope split to reproduce here. + // This and the herdr fakes further down this file all return a non-null object result for "tab + // list"/"tab close"/"workspace close", exactly what call() requires for success (client.go). All + // three are query commands, not callVoid's void pane commands, so no exit-code split to reproduce. if err := os.WriteFile(filepath.Join(bin, "herdr"), []byte("#!/bin/sh\ntouch '"+marker+"'\nprintf '{\"id\":\"cli:1\",\"result\":{\"tabs\":[{\"tab_id\":\"wA:tB\"}]}}'\n"), 0o755); err != nil { t.Fatal(err) } @@ -1259,10 +1229,9 @@ func writeAndCommit(t *testing.T, dir, name, content, message string) { runGitIn(t, dir, "commit", "-q", "-m", message) } -// diverge creates task-1-branch off the worktree's current HEAD, then advances -// main past it with a further commit to readmeContent - simulating the gate -// fix landing on main after the task branch forked - and leaves the worktree -// checked out on task-1-branch, still at the pre-fix commit. +// Creates task-1-branch off the worktree's current HEAD, then advances main past it with a further +// commit to readmeContent - the gate fix landing on main after the task branch forked - and leaves the +// worktree checked out on task-1-branch, still at the pre-fix commit. func diverge(t *testing.T, worktree, readmeContent string) { t.Helper() runGitIn(t, worktree, "branch", "task-1-branch") @@ -1270,10 +1239,9 @@ func diverge(t *testing.T, worktree, readmeContent string) { runGitIn(t, worktree, "checkout", "-q", "task-1-branch") } -// TestTeardownProceedsWhenDirtAlreadyMatchesMergedBase is atqamz/secondhand#79's -// safe case: the worktree's uncommitted edit to README.md reproduces byte-for-byte -// content main already carries (the no-mistakes gate's own re-edit of a file its -// merged fix already covers), so discarding it on teardown loses nothing. +// atqamz/secondhand#79's safe case: the worktree's uncommitted edit to README.md reproduces +// byte-for-byte content main already carries (the no-mistakes gate's own re-edit of a file its merged +// fix already covers), so discarding it on teardown loses nothing. func TestTeardownProceedsWhenDirtAlreadyMatchesMergedBase(t *testing.T) { home, worktree := setupTeardownHome(t) diverge(t, worktree, "fixed") @@ -1351,12 +1319,9 @@ func readTreehouseReturnArgs(t *testing.T, worktree string) []string { return args } -// TestTeardownForcesWorktreeReturnPastSafeDirt covers the step that follows the -// safe-dirt decision: treehouse will not clean a dirty worktree unprompted, and -// nothing here can answer its prompt, so a worktree teardown itself judged safe to -// discard has to be returned with --force even though the operator passed no -// --force flag. Without it the pool slot goes back dirty, or the return aborts -// after the task's tab is already closed. +// The step that follows the safe-dirt decision: treehouse will not clean a dirty worktree unprompted, +// and nothing here can answer its prompt, so a worktree teardown itself judged safe to discard has to +// be returned with --force even though the operator passed no --force flag. func TestTeardownForcesWorktreeReturnPastSafeDirt(t *testing.T) { home, worktree := setupTeardownHome(t) diverge(t, worktree, "fixed") @@ -1376,16 +1341,16 @@ func TestTeardownForcesWorktreeReturnPastSafeDirt(t *testing.T) { if err := cmd.Execute(); err != nil { t.Fatalf("got %v, want teardown to force the return of a worktree it judged safe", err) } + // Without it the pool slot goes back dirty, or the return aborts after the task's tab is closed. args := readTreehouseReturnArgs(t, worktree) if !slices.Contains(args, "--force") { t.Fatalf("treehouse return args = %v, want --force so the safe dirt is actually cleaned", args) } } -// TestTeardownReturnsCleanWorktreeUnforced is the counterpart: --force is the -// safe-dirt path's own doing, not something every teardown hands treehouse. A -// clean worktree keeps the ordinary unforced return, so treehouse's own guard -// still stands between teardown and any dirt this command never inspected. +// The counterpart: --force is the safe-dirt path's own doing, not something every teardown hands +// treehouse. A clean worktree keeps the ordinary unforced return, so treehouse's own guard still +// stands between teardown and any dirt this command never inspected. func TestTeardownReturnsCleanWorktreeUnforced(t *testing.T) { home, worktree := setupTeardownHome(t) writeFakeGHPRState(t, "MERGED") @@ -1406,11 +1371,9 @@ func TestTeardownReturnsCleanWorktreeUnforced(t *testing.T) { } } -// TestTeardownProceedsWhenDirtMatchesOriginDefaultBranchTip pins the ref -// resolution a real treehouse worktree actually takes: it has -// refs/remotes/origin/HEAD, so the base is origin's tip and not whatever the -// local default branch head happens to point at. Here local main has moved past -// origin/main, and only reading origin/main makes the dirt safe. +// Pins the ref resolution a real treehouse worktree takes: it has refs/remotes/origin/HEAD, so the +// base is origin's tip and not whatever the local default branch head points at. Here local main has +// moved past origin/main, and only reading origin/main makes the dirt safe. func TestTeardownProceedsWhenDirtMatchesOriginDefaultBranchTip(t *testing.T) { home, worktree := setupTeardownHome(t) runGitIn(t, worktree, "branch", "task-1-branch") @@ -1440,10 +1403,9 @@ func TestTeardownProceedsWhenDirtMatchesOriginDefaultBranchTip(t *testing.T) { } } -// TestTeardownRefusesDirtWhenStagedContentDiffersFromBase is the index half of the -// safety check: an "MM" path carries a third version in the index, and a working -// copy that matches the base says nothing about it. Comparing only the file on -// disk would discard that staged content. +// The index half of the safety check: an "MM" path carries a third version in the index, and a working +// copy that matches the base says nothing about it. Comparing only the file on disk would discard that +// staged content. func TestTeardownRefusesDirtWhenStagedContentDiffersFromBase(t *testing.T) { home, worktree := setupTeardownHome(t) diverge(t, worktree, "fixed") @@ -1470,10 +1432,9 @@ func TestTeardownRefusesDirtWhenStagedContentDiffersFromBase(t *testing.T) { } } -// TestTeardownRefusesDirtWhenContentDiffersFromBase is the counter-proof the brief -// asks for: README.md exists in base under the same path (both weaker checks - -// "the file exists in the base" and "the paths match" - would pass this), but its -// content differs from the worktree's uncommitted edit, so it must still refuse. +// The counter-proof the brief asks for: README.md exists in base under the same path (both weaker +// checks - "the file exists in the base" and "the paths match" - would pass this), but its content +// differs from the worktree's uncommitted edit, so it must still refuse. func TestTeardownRefusesDirtWhenContentDiffersFromBase(t *testing.T) { home, worktree := setupTeardownHome(t) diverge(t, worktree, "fixed") @@ -1496,10 +1457,8 @@ func TestTeardownRefusesDirtWhenContentDiffersFromBase(t *testing.T) { } } -// TestTeardownRefusesDirtWithUntrackedFileEvenWhenTrackedChangeMatchesBase proves -// an untracked file blocks on its own even when every tracked change is safe: -// there is nothing in the base to compare an untracked file against, so its mere -// presence must refuse, and the refusal must name it. +// An untracked file blocks on its own even when every tracked change is safe: there is nothing in the +// base to compare an untracked file against, so its mere presence must refuse, and name it. func TestTeardownRefusesDirtWithUntrackedFileEvenWhenTrackedChangeMatchesBase(t *testing.T) { home, worktree := setupTeardownHome(t) diverge(t, worktree, "fixed") @@ -1525,9 +1484,8 @@ func TestTeardownRefusesDirtWithUntrackedFileEvenWhenTrackedChangeMatchesBase(t } } -// TestTeardownRefusalCapsGitStatusOutput proves the refusal's git status dump is -// bounded (atqamz/secondhand#65 is the same lesson for report rendering): -// the first 20 entries print, the rest collapse to a count. +// The refusal's git status dump is bounded (atqamz/secondhand#65 is the same lesson for report +// rendering): the first 20 entries print, the rest collapse to a count. func TestTeardownRefusalCapsGitStatusOutput(t *testing.T) { home, worktree := setupTeardownHome(t) for i := 0; i < 25; i++ { diff --git a/cmd/update.go b/cmd/update.go index 41b0f54..0d1524a 100644 --- a/cmd/update.go +++ b/cmd/update.go @@ -50,20 +50,18 @@ func newUpdateCmd(version string) *cobra.Command { return err } - // The binary is already replaced by this point, so a failed - // AGENTS.md refresh or skeleton seed is reported as a warning - // rather than an error: exiting nonzero here reads as "the update - // failed" and invites a pointless re-run. + // The binary is already replaced by this point, so a failed AGENTS.md refresh or skeleton seed is + // reported as a warning rather than an error: exiting nonzero here reads as "the update failed" and + // invites a pointless re-run. var refreshed, hooked bool var seedErr, hookErr error fleetHome, refreshErr := home.Resolve() switch { case refreshErr == nil: refreshed, refreshErr = agentsmd.Refresh(fleetHome) - // The refreshed template directs the agent at data files an - // older home never had, so the command that installs it also - // leaves those files in place - directories included, since a - // home resolves as one on its state/hand.db marker alone. + // The refreshed template directs the agent at data files an older home never had, so the command + // that installs it also leaves those files in place - directories included, since a home resolves + // as one on its state/hand.db marker alone. seedErr = initLayout(fleetHome) // An install that moved leaves the session hook pointing at a // path with no binary behind it any more. diff --git a/cmd/update_test.go b/cmd/update_test.go index f809769..0aa2322 100644 --- a/cmd/update_test.go +++ b/cmd/update_test.go @@ -16,10 +16,9 @@ import ( "github.com/atqamz/secondhand/internal/selfupdate" ) -// writeFakeGHReleaseView fakes "release view --jq" as real gh's --jq flattens -// its JSON to the raw field value on stdout (selfupdate.go's runGH callers use -// --jq .tagName/.body), so a plain string with exit 0 is the faithful shape - -// no envelope to reproduce here, unlike herdr's call()/callVoid(). +// Fakes "release view --jq" as real gh's --jq flattens its JSON to the raw field value on stdout +// (selfupdate.go's runGH callers use --jq .tagName/.body), so a plain string with exit 0 is the faithful +// shape - no envelope to reproduce here, unlike herdr's call()/callVoid(). func writeFakeGHReleaseView(t *testing.T, tag string) { t.Helper() bin := t.TempDir() @@ -30,16 +29,14 @@ func writeFakeGHReleaseView(t *testing.T, tag string) { t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) } -// writeFakeGHUpdate fakes the three gh invocations a full `hand update` makes: -// the tag lookup and the release notes lookup, both "release view --jq" in the -// shape writeFakeGHReleaseView documents above, plus the asset download - real -// `gh release download` leaves only the assets themselves in --dir and writes -// its progress to stderr, so copying the fixture in and printing nothing is the -// faithful success shape. The trailing arm mirrors real gh's failure shape too: -// a diagnostic on stderr and a non-zero exit, never a partial success. +// Fakes the three gh invocations a full `hand update` makes: the tag lookup and the release notes lookup, +// both "release view --jq" in the shape writeFakeGHReleaseView documents above, plus the asset download. func writeFakeGHUpdate(t *testing.T, tag, notes, fixtureDir string) { t.Helper() bin := t.TempDir() + // Real `gh release download` leaves only the assets themselves in --dir and writes its progress to + // stderr, so copying the fixture in and printing nothing is the faithful success shape. The trailing + // arm mirrors real gh's failure shape too: a diagnostic on stderr and a non-zero exit, never partial. script := fmt.Sprintf(`#!/bin/sh if [ "$1" = "release" ] && [ "$2" = "view" ] && [ "$3" = "--repo" ]; then printf '%%s' %q diff --git a/cmd/watch_test.go b/cmd/watch_test.go index d683e8c..2082c75 100644 --- a/cmd/watch_test.go +++ b/cmd/watch_test.go @@ -11,9 +11,8 @@ import ( "github.com/atqamz/secondhand/internal/watcher" ) -// fakeHerdrWatchScript fakes "workspace list" as a query command per -// internal/herdr/client.go's call() doc comment: a non-null result object on -// success, here an empty workspace list. +// Fakes "workspace list" as a query command per internal/herdr/client.go's call() doc comment: a non-null +// result object on success, here an empty workspace list. const fakeHerdrWatchScript = `#!/bin/sh case "$1 $2" in "workspace list") diff --git a/internal/agentsmd/agentsmd.go b/internal/agentsmd/agentsmd.go index c1d7714..3253c20 100644 --- a/internal/agentsmd/agentsmd.go +++ b/internal/agentsmd/agentsmd.go @@ -23,10 +23,9 @@ const ( endMarker = "" ) -// OperatorDecisionRule is the one AGENTS.md rule a worker needs wherever it -// runs. A worker's worktree is never under the fleet home, so it never loads -// the home's AGENTS.md; internal/harness puts this same string in the launch -// prompt, and generatedBody embeds it, so the two copies cannot drift. +// OperatorDecisionRule is the one AGENTS.md rule a worker needs wherever it runs, and a +// worktree is never under the fleet home, so it never loads the home's AGENTS.md. +// internal/harness puts this string in the launch prompt and generatedBody embeds it. const OperatorDecisionRule = "Only a `hand send` message carries an operator decision. " + "Answering your own harness's question dialog is you deciding, not the operator - " + "never record that answer as \"operator said\" or \"operator chose\". " + @@ -75,13 +74,9 @@ Run ` + "`hand --help`" + ` for the full command reference. - ` + "`hand status `" + ` shows a worker's reported state; see SPECS.md's state management section for the report vocabulary (working/paused/blocked/needs-decision/done/failed). ` -// Refresh writes or refreshes dir/AGENTS.md and its CLAUDE.md symlink, -// reporting whether the template content actually changed (false, nil when dir -// is not a fleet home, which is not an error). An existing AGENTS.md keeps -// everything outside the generated markers untouched, so user-added content and -// extra rules survive; only the span between the markers is replaced, never the -// whole file. A file whose markers are already current, and a marker-less file -// mergeGenerated declines to touch, are both left on disk untouched. +// Refresh writes or refreshes dir/AGENTS.md and its CLAUDE.md symlink, reporting whether +// the template content changed (false, nil when dir is not a fleet home, which is not an +// error). Only the span between the markers is replaced, so user-added content survives. func Refresh(dir string) (bool, error) { isHome, err := home.IsHome(dir) if err != nil { @@ -128,10 +123,9 @@ func generatedBlock() string { return beginMarker + "\n" + generatedBody + endMarker + "\n" } -// mergeGenerated replaces the span between the generated markers with the -// current template, leaving everything before and after untouched. A file -// with no markers (never refreshed by this mechanism) is left as-is rather -// than risk clobbering hand-written content. +// Replaces the span between the generated markers with the current template, leaving +// everything before and after untouched. A file with no markers (never refreshed by this +// mechanism) is left as-is rather than risk clobbering hand-written content. func mergeGenerated(content string) string { start, end, ok := generatedBlockSpan(content) if !ok { @@ -140,9 +134,8 @@ func mergeGenerated(content string) string { return content[:start] + strings.TrimSuffix(generatedBlock(), "\n") + content[end:] } -// generatedBlockSpan returns the byte range of the generated block, including -// its markers, or ok=false when the markers are absent or malformed (an end -// marker missing, or appearing before any begin marker). +// Returns the byte range of the generated block, markers included, or ok=false when the +// markers are absent or malformed (an end marker missing, or before any begin marker). func generatedBlockSpan(content string) (start, end int, ok bool) { start = strings.Index(content, beginMarker) if start == -1 { @@ -156,28 +149,22 @@ func generatedBlockSpan(content string) (start, end int, ok bool) { } var ( - // dateRe and selfExpiringRe describe the two shapes of perishable content - // that belong in the fleet home's own notes, not AGENTS.md: a dated fact - // is an incident, and phrasing that names its own expiry is not an - // invariant. hand does not create or own that notes convention, so - // neither the violation text nor SPECS.md names a specific path for it. + // The two shapes of perishable content that belong in the fleet home's own notes + // rather than AGENTS.md: a dated fact is an incident, and phrasing that names its + // own expiry is not an invariant. hand does not own that notes convention. dateRe = regexp.MustCompile(`\b\d{4}-\d{2}-\d{2}\b`) selfExpiringRe = regexp.MustCompile(`(?i)\b(?:until|once)\s+#\d+\s+lands\b|\bawaiting\s+#\d+\b`) - // inlineCodeRe and urlRe are stripped from a line before it's tested against - // dateRe/selfExpiringRe: a date in a quoted example or a URL is not an - // incident, and flagging it anyway is the false positive that gets a + // Stripped from a line before it is tested above: a date in a quoted example or a + // URL is not an incident, and flagging it anyway is the false positive that gets a // checker ignored (atqamz/secondhand#90). inlineCodeRe = regexp.MustCompile("`[^`]*`") urlRe = regexp.MustCompile(`https?://\S+`) ) // Severity distinguishes a Violation that fails hand doctor from one that is -// informational: reported because it is real and worth a human's attention, -// but not something the checker can resolve into a pass/fail verdict on its -// own - the marker-less case below is the only one so far, since Check has -// no way to tell a file left marker-less by accident from one left that way -// on purpose. +// informational: real and worth a human's attention, but not something the checker can +// resolve into a pass/fail verdict on its own. Absent markers are the only case so far. type Severity int const ( @@ -185,25 +172,18 @@ const ( SeverityInfo ) -// Violation is one perishable-content, malformed-file, or generated-block hit -// Check found, at SeverityViolation unless Severity says otherwise. Line is -// 1-based, or 0 for a violation that isn't about a single line (a drifted or -// absent generated block). +// Violation is one perishable-content, malformed-file or generated-block hit Check found, +// at SeverityViolation unless Severity says otherwise. Line is 1-based, or 0 when the hit +// is not about a single line (a drifted or absent generated block). type Violation struct { Line int Text string Severity Severity } -// Check reports perishable content, an unterminated code fence, and either -// generated-block drift or generated markers absent altogether in dir's -// AGENTS.md, described in SPECS.md's "AGENTS.md (target)" section -// (atqamz/secondhand#90). Absent markers come back at SeverityInfo rather -// than SeverityViolation, since Check cannot tell a marker-less file left -// that way by accident from one left that way on purpose. It never writes: -// the point is to make a human look at prose judgment a machine cannot make, -// not to rewrite it. A nil result with no error means dir is not a fleet -// home, or has no AGENTS.md yet - both are an absence, not a violation. +// Check reports perishable content, an unterminated code fence, and generated-block drift or absence in +// dir's AGENTS.md without fixing any of it, so a human looks at the prose judgment a machine cannot make +// (SPECS.md's "AGENTS.md (target)"). A nil result with no error is an absence - no fleet home, or no file. func Check(dir string) ([]Violation, error) { isHome, err := home.IsHome(dir) if err != nil { @@ -265,6 +245,8 @@ func Check(dir string) ([]Violation, error) { switch { case !hasBlock: + // Info rather than a failure: a file left marker-less by accident is indistinguishable from one + // left that way on purpose (atqamz/secondhand#90). violations = append(violations, Violation{ Text: "no hand:generated markers: hand init and hand update leave a marker-less file alone, so this template can never refresh itself here - paste the current generated block back in if that is unintended, or ignore this finding if the file is deliberately hand-authored (see SPECS.md's \"AGENTS.md (target)\" section)", Severity: SeverityInfo, @@ -285,10 +267,9 @@ func firstBannedRune(line string) (rune, bool) { return 0, false } -// isEmojiRune covers the Unicode blocks an accidental emoji actually comes -// from, not a formal emoji property table: pictographs, symbols/dingbats, -// regional-indicator flag letters, and the variation-selector/ZWJ modifiers -// that ride along with them. +// Covers the Unicode blocks an accidental emoji actually comes from, not a formal emoji +// property table: pictographs, symbols/dingbats, regional-indicator flag letters, and the +// variation-selector/ZWJ modifiers that ride along with them. func isEmojiRune(r rune) bool { switch { case r >= 0x1F300 && r <= 0x1FAFF: diff --git a/internal/agentsmd/agentsmd_test.go b/internal/agentsmd/agentsmd_test.go index 1234ee2..1fff80a 100644 --- a/internal/agentsmd/agentsmd_test.go +++ b/internal/agentsmd/agentsmd_test.go @@ -185,10 +185,9 @@ func TestThisRepoAgentsMdPointsAtGeneratedBody(t *testing.T) { } } -// #87's fix has to reach every worker without a new report-vocabulary word, -// since internal/watcher's classifier and hand status's renderer are outside -// this change's scope: reusing the working: prefix with a first-person -// convention is the only lever available. +// atqamz/secondhand#87's fix has to reach every worker without a new report-vocabulary word, since the +// watcher's classifier and hand status's renderer were outside that change's scope: the working: prefix +// plus a first-person convention was the only lever available. func TestGeneratedRulesCoverSelfDecidedCallsInFirstPerson(t *testing.T) { if !strings.Contains(generatedBody, "hand send") || !strings.Contains(generatedBody, "operator decision") { t.Fatalf("got generated body %q, want the hand send invariant", generatedBody) @@ -207,10 +206,9 @@ func TestGeneratedRulesCoverHolds(t *testing.T) { } } -// atqamz/secondhand#47: the four files hand init seeds are inert unless the -// template says who reads each one and when, and atqamz/secondhand#64: the one -// direction data/ does not carry has to be stated, or the agent invents a -// hand-written operator channel again. +// atqamz/secondhand#47: the four files hand init seeds are inert unless the template says +// who reads each one and when. atqamz/secondhand#64: the one direction data/ does not carry +// has to be stated, or the agent invents a hand-written operator channel again. func TestGeneratedRulesCoverOperatorContextLearningsAndArchives(t *testing.T) { for _, want := range []string{ "data/operator.md", diff --git a/internal/brief/brief.go b/internal/brief/brief.go index ee47c92..af96cad 100644 --- a/internal/brief/brief.go +++ b/internal/brief/brief.go @@ -13,10 +13,9 @@ type Declaration struct { Effort string } -// Parse ignores unknown keys inside the block, and reports "no declaration" for a brief it -// cannot scan (an unterminated fence, a line past bufio's token cap), by choice rather than -// oversight: a brief is prose carrying two optional settings, not a config file, so nothing -// about its shape may fail a spawn. +// Parse ignores unknown keys inside the block and reports "no declaration" for a brief it +// cannot scan (an unterminated fence, a line past bufio's token cap) by choice: a brief is +// prose carrying two optional settings, not a config file, so its shape may not fail a spawn. func Parse(path string) (Declaration, bool, error) { f, err := os.Open(path) if err != nil { diff --git a/internal/brief/brief_test.go b/internal/brief/brief_test.go index b3f0190..7a51e49 100644 --- a/internal/brief/brief_test.go +++ b/internal/brief/brief_test.go @@ -8,10 +8,9 @@ import ( "testing" ) -// realBriefFixture is a verbatim copy of data/secondhand-th-floor/brief.md from the fleet -// this repo runs, chosen because it opens with plain prose ("You are a crewmate...") rather -// than a "#" heading - the shape most likely to trip up a header parser that scans for -// "key: value" lines before the first heading instead of requiring an explicit "---" fence. +// Verbatim copy of data/secondhand-th-floor/brief.md from the fleet this repo runs, chosen +// because it opens with plain prose rather than a "#" heading - the shape most likely to +// trip a parser that scans for "key: value" lines instead of requiring a "---" fence. const realBriefFixture = `You are a crewmate: an autonomous worker agent managed by the first mate. Work on your own; do not wait for a human. # Task diff --git a/internal/completion/completion.go b/internal/completion/completion.go index 7a4dac8..eeff08d 100644 --- a/internal/completion/completion.go +++ b/internal/completion/completion.go @@ -28,15 +28,9 @@ func Path(homeDir string) string { return filepath.Join(state.Dir(homeDir), "completions.jsonl") } -// Append adds r as the store's newest line. Concurrent Append calls, including -// from other hand processes, stay intact because of what this deliberately does -// not do: state/events.log's appendEventLog reads the whole file, adds a line, -// and atomically replaces it, so two writers racing that read-modify-write can -// each read the same old content and one's line clobbers the other's on -// rename. Append instead takes the same named-lock primitive hand's other -// command sequences use, serializing against any other Append, and writes one complete -// line with a single O_APPEND syscall - no read, no rename, nothing for a -// second writer to race. +// Append adds r as the store's newest line. Concurrent calls, including from other +// hand processes, stay intact by avoiding events.log's read-modify-write-rename race: +// a named lock plus one O_APPEND write leaves a second writer nothing to clobber. func Append(homeDir string, r Record) error { release, err := state.Lock(homeDir, "completions") if err != nil { @@ -59,24 +53,18 @@ func Append(homeDir string, r Record) error { _ = f.Close() return fmt.Errorf("append completion record %q: %w", r.ID, err) } - // Not deferred and not discarded: a filesystem that reports a write fault - // only at close (NFS, delayed-allocation ENOSPC) makes Close the sole signal - // that the record reached disk, and teardown removes the task's row on the - // strength of this call returning nil. + // Not deferred and not discarded: a filesystem that reports a write fault only + // at close (NFS, delayed-allocation ENOSPC) makes Close the sole signal the record + // reached disk, and teardown removes the task's row on this returning nil. if err := f.Close(); err != nil { return fmt.Errorf("close completions store: %w", err) } return nil } -// List returns every record in the store, oldest first. Returns nil if the -// store doesn't exist yet. -// -// A line that does not parse is skipped rather than failing the read. A -// truncated write (a short write on ENOSPC leaves a partial line that the next -// O_APPEND write glues a complete object onto) damages one line, and a store -// whose purpose is surviving loss must not let that one line hide every good -// record written before it. +// List returns every record, oldest first, and nil if the store doesn't exist yet. A +// line that does not parse is skipped: a short write leaves a partial line the next +// O_APPEND glues onto, and one damaged line must not hide the good records before it. func List(homeDir string) ([]Record, error) { f, err := os.Open(Path(homeDir)) if os.IsNotExist(err) { diff --git a/internal/completion/completion_test.go b/internal/completion/completion_test.go index 4edbcdc..f6d4e19 100644 --- a/internal/completion/completion_test.go +++ b/internal/completion/completion_test.go @@ -44,8 +44,8 @@ func TestListMissingStore(t *testing.T) { } } -// TestAppendUncapped is the storage half of atqamz/secondhand#61's "uncapped, or -// capped somewhere other than the display layer" requirement. +// The storage half of atqamz/secondhand#61's "uncapped, or capped somewhere other +// than the display layer" requirement. func TestAppendUncapped(t *testing.T) { dir := t.TempDir() const n = 25 @@ -64,9 +64,8 @@ func TestAppendUncapped(t *testing.T) { } } -// TestListSkipsDamagedLine covers the read semantic a durable store needs: a -// truncated write leaves one unparseable line, and every good record around it -// must still be readable. +// Covers the read semantic a durable store needs: a truncated write leaves one +// unparseable line, and every good record around it must still be readable. func TestListSkipsDamagedLine(t *testing.T) { dir := t.TempDir() first := Record{ID: "before", Project: "nsr", Kind: "ship", Outcome: "merged", Detail: "PR 1", TornDownAt: "2026-08-02T13:00:00Z"} @@ -105,9 +104,9 @@ func TestListSkipsDamagedLine(t *testing.T) { } } -// TestAppendConcurrent is the concurrency guarantee the brief for #61 requires -// explaining: two Append calls racing must never lose either line, unlike -// state/events.log's read-modify-write-whole-file pattern. +// The concurrency guarantee the brief for atqamz/secondhand#61 requires explaining: two Append calls +// racing must never lose either line, unlike state/events.log's read-modify-write pattern over the +// whole file. func TestAppendConcurrent(t *testing.T) { dir := t.TempDir() const n = 50 diff --git a/internal/faketool/FIDELITY.md b/internal/faketool/FIDELITY.md index 53b444d..8c61cbf 100644 --- a/internal/faketool/FIDELITY.md +++ b/internal/faketool/FIDELITY.md @@ -136,6 +136,7 @@ Identifiers are assigned by herdr: workspaces `wX`, their tabs `wX:t1`, their pa Exit 0. The result carries the workspace, the root tab herdr creates with it, and that tab's root pane, all three in one response. The root tab's label is `1`, not the label the workspace was given, which is why `hand spawn` renames it. +A workspace a human opens instead gets its label from its root directory's basename, so a directory named after a project produces a workspace whose bare label is the key `hand` searches on. State left behind: the workspace is listed by `workspace list` from this point on. @@ -178,6 +179,7 @@ Exit 0, and the new label is what `tab list` reports from then on. Exit 0 with bare text, and **empty for a pane whose own shell has not painted yet** - a read taken immediately after `workspace create` returns nothing at all. The two sources also disagree in that window: `visible` can carry the screen while `recent` is still empty. +They disagree on height too: `visible` answers the current viewport, 23 rows in an unattached session against 61 in an attached one, so anything painted above that window is absent from it while `recent` still carries it. So a single read proves nothing about a pane, which is why `confirmLaunch` polls rather than reading once, and why the fake answering the same text on every read is only ever the settled case. Text `pane run` typed into the pane appears in a later read, so a read reflects what herdr sent as well as what the command produced. diff --git a/internal/ghutil/pr.go b/internal/ghutil/pr.go index 42af30f..463413e 100644 --- a/internal/ghutil/pr.go +++ b/internal/ghutil/pr.go @@ -14,9 +14,9 @@ import ( "strings" ) -// PRIsMerged reports whether the PR is merged. gh writes warnings to stderr ahead of -// the JSON, so the payload must be read from stdout alone; CombinedOutput here corrupts -// the parse (issue #21). +// PRIsMerged reports whether the PR is merged. gh writes warnings to stderr ahead of the JSON, so the +// payload must be read from stdout alone; CombinedOutput here corrupts the parse +// (atqamz/secondhand#21). func PRIsMerged(ctx context.Context, pr string) (bool, error) { cmd := exec.CommandContext(ctx, "gh", "pr", "view", pr, "--json", "state") var stderr bytes.Buffer @@ -37,12 +37,9 @@ func PRIsMerged(ctx context.Context, pr string) (bool, error) { // PRSearchTarget names one repo FindPRByBranch searches for a head ref. type PRSearchTarget struct { Repo string - // HeadRepo, when set, keeps only PRs whose head branch lives in that repo. - // A fork project's upstream carries head refs from every contributor's fork, - // so a branch name alone can match a stranger's PR there; the fork project - // knows which repo its own branch is pushed to. Compared case-insensitively: - // gh reports a repo in its canonical casing while this value is derived from - // whatever casing the clone's origin remote was written in. + // When set, keeps only PRs whose head branch lives in that repo: a fork's upstream carries head + // refs from every contributor, so a branch name alone can match a stranger's PR there. Folded, + // because gh reports canonical casing while this comes from the clone's origin remote. HeadRepo string } @@ -54,13 +51,9 @@ type PRCandidate struct { State string } -// AmbiguousPRError reports that a branch's PRs do not resolve to a usable -// winner: either no preference tier yields a single match (two merged, two -// open, ...), or a merged PR coexists with an open one, which refuses by rule -// even though the merged tier has a lone match. Candidates names every PR on -// the head ref, whatever its state and whichever searched repo it came from, -// not just the tier that triggered the refusal. FindPRByBranch returns this -// instead of guessing; the caller decides. +// AmbiguousPRError reports that a branch's PRs do not resolve to a usable winner: no preference +// tier yields a single match, or a merged PR coexists with an open one, which refuses by rule. +// Candidates names every PR on the head ref, any state and any searched repo, not just that tier. type AmbiguousPRError struct { Branch string Candidates []PRCandidate @@ -74,35 +67,13 @@ func (e *AmbiguousPRError) Error() string { return fmt.Sprintf("ambiguous PR for branch %s: %s", e.Branch, strings.Join(parts, ", ")) } -// FindPRByBranch reports the PR across targets whose head ref is exactly branch - -// the only rule hand uses to associate a PR with a task, never a title, issue -// number or task id. --state all is required because gh pr list defaults to -// open only, and a gate-opened PR may already be merged or closed by the time -// hand looks for it; found is false when no PR has that head ref. -// -// More than one target is how a fork project finds its PR: the branch is pushed -// to the fork while the PR is opened on the declared upstream, so both repos are -// searched. gh's --head takes the plain branch name even for a cross-repo PR -// (the qualified owner:branch form matches nothing), so the upstream target -// carries a HeadRepo to keep a same-named branch from another fork out. Matches -// from every target resolve through one tier pass, so a PR on the fork and one -// on the upstream are ambiguous exactly like two in one repo. -// -// A branch can carry more than one PR (a closed-unmerged one plus a reopened -// replacement, say), so results are resolved by preference tier rather than -// picked arbitrarily: merged, then open, then closed-unmerged. A tier with more -// than one match is ambiguous, and so is a merged PR coexisting with an open one: -// an open PR on the same head ref is live evidence the branch may still carry -// unlanded work, so that mix refuses rather than resolving to the merged PR. -// Either case returns AmbiguousPRError naming every candidate, rather than -// guessing (atqamz/secondhand#77) - the same guess that let -// cmd/teardown.go's landed-work guard trust a merged PR while the branch's -// real state was closed-unmerged. That rule is only sound on the complete -// set of PRs for the head ref, so --limit is stated explicitly rather than -// left on gh pr list's implicit 30: the cap is set far above any realistic -// count for one branch, so a same-tier duplicate cannot be truncated out of -// the page and silently resolve as a single winner. +// FindPRByBranch reports the PR across targets whose head ref is exactly branch - the only rule +// hand uses to associate a PR with a task, never a title, an issue number or a task id. found is +// false when no PR carries that head ref. func FindPRByBranch(ctx context.Context, branch string, targets ...PRSearchTarget) (url string, merged bool, found bool, err error) { + // More than one target is how a fork project finds its PR: the branch is pushed to the fork + // while the PR is opened on the declared upstream. Matches from every target resolve through + // one tier pass, so a fork PR and an upstream PR are ambiguous exactly like two in one repo. var results []prListItem for _, target := range targets { found, err := listPRsByBranch(ctx, target, branch) @@ -115,6 +86,9 @@ func FindPRByBranch(ctx context.Context, branch string, targets ...PRSearchTarge return "", false, false, nil } + // A branch can carry more than one PR - a closed-unmerged one plus a reopened replacement - so + // results resolve by preference tier rather than arbitrarily: merged, then open, then + // closed-unmerged, and a tier holding more than one match is ambiguous. var mergedPRs, openPRs, closedPRs []prListItem for _, r := range results { switch r.State { @@ -127,6 +101,9 @@ func FindPRByBranch(ctx context.Context, branch string, targets ...PRSearchTarge } } + // A merged PR coexisting with an open one refuses too: the open PR is live evidence the branch + // may still carry unlanded work. Guessing here is what let cmd/teardown.go's landed-work guard + // trust a merged PR while the branch's real state was closed-unmerged (atqamz/secondhand#77). if len(mergedPRs) > 0 && len(openPRs) > 0 { return "", false, false, ambiguousPRError(branch, results) } @@ -145,6 +122,9 @@ func FindPRByBranch(ctx context.Context, branch string, targets ...PRSearchTarge } func listPRsByBranch(ctx context.Context, target PRSearchTarget, branch string) ([]prListItem, error) { + // --state all because gh pr list defaults to open only and a gate-opened PR may already be + // merged or closed; --limit stated rather than left on gh's implicit 30, far above any real + // count for one branch, so a same-tier duplicate cannot be truncated into a lone winner. cmd := exec.CommandContext(ctx, "gh", "pr", "list", "--repo", target.Repo, "--head", branch, "--state", "all", "--limit", "200", "--json", "number,url,state,headRepository") var stderr bytes.Buffer cmd.Stderr = &stderr @@ -156,6 +136,9 @@ func listPRsByBranch(ctx context.Context, target PRSearchTarget, branch string) if err := json.Unmarshal(out, &results); err != nil { return nil, fmt.Errorf("parse gh pr list output: %w", err) } + // gh's --head takes the plain branch name even for a cross-repo PR (the qualified owner:branch + // form matches nothing), so the upstream target carries a HeadRepo to keep a same-named branch + // from another fork out. kept := make([]prListItem, 0, len(results)) for _, r := range results { if target.HeadRepo != "" && !strings.EqualFold(r.HeadRepository.NameWithOwner, target.HeadRepo) { @@ -175,9 +158,9 @@ func ambiguousPRError(branch string, matches []prListItem) *AmbiguousPRError { return &AmbiguousPRError{Branch: branch, Candidates: candidates} } -// prListItem is one entry of `gh pr list --json number,url,state,headRepository`. -// Repo is the searched repo, not part of gh's payload: a match's own repo has to -// survive into an AmbiguousPRError naming PRs from two repos at once. +// One entry of `gh pr list --json number,url,state,headRepository`. Repo is the searched repo, not +// part of gh's payload: a match's own repo has to survive into an AmbiguousPRError naming PRs from +// two repos at once. type prListItem struct { Number int `json:"number"` URL string `json:"url"` diff --git a/internal/ghutil/pr_test.go b/internal/ghutil/pr_test.go index 9b0588f..bfe6d75 100644 --- a/internal/ghutil/pr_test.go +++ b/internal/ghutil/pr_test.go @@ -11,9 +11,9 @@ import ( "unicode" ) -// writeFakeGHPRView fakes `gh pr view --json state`, emitting a stderr line -// ahead of the JSON payload so a CombinedOutput regression at the call site -// fails the parse the same way real gh's progress output does. +// Fakes `gh pr view --json state`, emitting a stderr line ahead of the JSON payload so a +// CombinedOutput regression at the call site fails the parse the same way real gh's progress +// output does. func writeFakeGHPRView(t *testing.T, state string, exitCode int, stderrLine string) { t.Helper() bin := t.TempDir() @@ -59,10 +59,9 @@ func TestPRIsMergedReportsExitStatusWithoutStderr(t *testing.T) { } } -// writeFakeGHPRList fakes `gh pr list --json number,url,state,headRepository`, -// emitting a stderr line ahead of the JSON array payload for the same reason -// writeFakeGHPRView does: a CombinedOutput regression at the call site must -// fail the parse. +// Fakes `gh pr list --json number,url,state,headRepository`, emitting a stderr line ahead of the +// JSON array payload for the same reason writeFakeGHPRView does: a CombinedOutput regression at +// the call site must fail the parse. func writeFakeGHPRList(t *testing.T, body string, exitCode int, stderrLine string) { t.Helper() bin := t.TempDir() @@ -92,10 +91,9 @@ func TestFindPRByBranchReturnsMatch(t *testing.T) { } } -// TestFindPRByBranchPrefersMergedOverClosedUnmerged is the atqamz/secondhand#77 -// regression: a branch carrying a merged PR alongside a closed-unmerged one -// (a duplicate opened by mistake, say) must resolve to the merged PR rather -// than an arbitrary pick. +// The atqamz/secondhand#77 regression: a branch carrying a merged PR alongside a closed-unmerged +// one (a duplicate opened by mistake, say) must resolve to the merged PR rather than an arbitrary +// pick. func TestFindPRByBranchPrefersMergedOverClosedUnmerged(t *testing.T) { writeFakeGHPRList(t, `[{"number":9,"url":"https://github.com/owner/repo/pull/9","state":"CLOSED"},`+ `{"number":5,"url":"https://github.com/owner/repo/pull/5","state":"MERGED"}]`, 0, "") @@ -108,9 +106,8 @@ func TestFindPRByBranchPrefersMergedOverClosedUnmerged(t *testing.T) { } } -// TestFindPRByBranchReturnsSoleClosedUnmergedPR proves a branch with only a -// closed-unmerged PR still resolves to it rather than treating the tier rule -// as requiring a merged candidate to exist. +// Proves a branch with only a closed-unmerged PR still resolves to it rather than treating the +// tier rule as requiring a merged candidate to exist. func TestFindPRByBranchReturnsSoleClosedUnmergedPR(t *testing.T) { writeFakeGHPRList(t, `[{"number":9,"url":"https://github.com/owner/repo/pull/9","state":"CLOSED"}]`, 0, "") url, merged, found, err := FindPRByBranch(context.Background(), "task-1-branch", PRSearchTarget{Repo: "owner/repo"}) @@ -122,8 +119,7 @@ func TestFindPRByBranchReturnsSoleClosedUnmergedPR(t *testing.T) { } } -// TestFindPRByBranchRefusesTwoMergedPRs proves an ambiguous winning tier -// refuses rather than picking either candidate. +// Proves an ambiguous winning tier refuses rather than picking either candidate. func TestFindPRByBranchRefusesTwoMergedPRs(t *testing.T) { writeFakeGHPRList(t, `[{"number":9,"url":"https://github.com/owner/repo/pull/9","state":"MERGED"},`+ `{"number":5,"url":"https://github.com/owner/repo/pull/5","state":"MERGED"}]`, 0, "") @@ -140,10 +136,8 @@ func TestFindPRByBranchRefusesTwoMergedPRs(t *testing.T) { } } -// TestFindPRByBranchRefusesTwoMergedPRsNamesClosedCandidateToo proves the -// same-tier refusal names every PR on the head ref, including one sitting in a -// losing tier, so the operator resolves the whole branch rather than the pair -// that happened to trigger the refusal. +// Proves the same-tier refusal names every PR on the head ref, including one sitting in a losing +// tier, so the operator resolves the whole branch rather than the pair that triggered the refusal. func TestFindPRByBranchRefusesTwoMergedPRsNamesClosedCandidateToo(t *testing.T) { writeFakeGHPRList(t, `[{"number":7,"url":"https://github.com/owner/repo/pull/7","state":"MERGED"},`+ `{"number":5,"url":"https://github.com/owner/repo/pull/5","state":"MERGED"},`+ @@ -161,9 +155,8 @@ func TestFindPRByBranchRefusesTwoMergedPRsNamesClosedCandidateToo(t *testing.T) } } -// TestFindPRByBranchRefusesMergedAndOpenPR proves a branch carrying both a -// merged PR and a still-open one refuses rather than resolving to the merged -// PR: the open PR is live evidence the branch may carry unlanded work. +// Proves a branch carrying both a merged PR and a still-open one refuses rather than resolving to +// the merged PR: the open PR is live evidence the branch may carry unlanded work. func TestFindPRByBranchRefusesMergedAndOpenPR(t *testing.T) { writeFakeGHPRList(t, `[{"number":5,"url":"https://github.com/owner/repo/pull/5","state":"MERGED"},`+ `{"number":9,"url":"https://github.com/owner/repo/pull/9","state":"OPEN"}]`, 0, "") @@ -180,9 +173,8 @@ func TestFindPRByBranchRefusesMergedAndOpenPR(t *testing.T) { } } -// TestFindPRByBranchRefusesMergedAndOpenPRNamesClosedCandidateToo proves the -// merged+open refusal names every candidate on the branch, including a -// coexisting closed-unmerged PR, not just the merged and open ones. +// Proves the merged+open refusal names every candidate on the branch, including a coexisting +// closed-unmerged PR, not just the merged and open ones. func TestFindPRByBranchRefusesMergedAndOpenPRNamesClosedCandidateToo(t *testing.T) { writeFakeGHPRList(t, `[{"number":5,"url":"https://github.com/owner/repo/pull/5","state":"MERGED"},`+ `{"number":9,"url":"https://github.com/owner/repo/pull/9","state":"OPEN"},`+ @@ -200,8 +192,7 @@ func TestFindPRByBranchRefusesMergedAndOpenPRNamesClosedCandidateToo(t *testing. } } -// TestFindPRByBranchPrefersOpenOverClosedUnmerged pins the open-over-closed -// tier boundary: with no merged PR on the branch, an open PR beats a +// Pins the open-over-closed tier boundary: with no merged PR on the branch, an open PR beats a // closed-unmerged one rather than either being an arbitrary loop-order pick. func TestFindPRByBranchPrefersOpenOverClosedUnmerged(t *testing.T) { writeFakeGHPRList(t, `[{"number":9,"url":"https://github.com/owner/repo/pull/9","state":"CLOSED"},`+ @@ -215,27 +206,24 @@ func TestFindPRByBranchPrefersOpenOverClosedUnmerged(t *testing.T) { } } -// writeFakeGHPRListPerRepo fakes `gh pr list` for a fork search, dispatching on -// the --repo argument the old single-body fake ignored: without that, no test can -// express "the PR is on repo B while the project's repo is A" and a fork test -// would pass against a shape gh never returns (atqamz/secondhand#40). It also -// refuses a qualified owner:branch --head value, which real gh silently answers -// with an empty list - verified against gh 2.97 on a live cross-repo PR - so the -// mistake surfaces as a failure here instead of as "no PR found" in production. -// The dispatch case-folds --repo because GitHub serves a repo under any casing of -// its slug, so a fake matching case-sensitively would answer a differently-cased -// target with an empty list and hide the hit real gh returns. It folds with a glob -// rather than tr: PATH is replaced by the fake's own dir alone, so no external -// binary resolves inside these scripts. +// Fakes `gh pr list` for a fork search, dispatching on the --repo argument the old single-body +// fake ignored: without that, no test can express "the PR is on repo B while the project's repo is +// A", and a fork test would pass against a shape gh never returns (atqamz/secondhand#40). func writeFakeGHPRListPerRepo(t *testing.T, bodies map[string]string) { t.Helper() bin := t.TempDir() + // A qualified owner:branch --head value is refused below because real gh silently answers it + // with an empty list - verified against gh 2.97 on a live cross-repo PR - so the mistake + // surfaces as a failure here instead of as "no PR found" in production. script := "#!/bin/sh\n" + "[ \"$1 $2\" = \"pr list\" ] || { echo \"unexpected gh args: $@\" >&2; exit 1; }\n" + "[ \"$3\" = \"--repo\" ] && [ \"$5\" = \"--head\" ] || { echo \"unexpected gh args: $@\" >&2; exit 1; }\n" + "case \"$6\" in *:*) echo \"qualified head ref matches nothing in real gh: $6\" >&2; exit 1 ;; esac\n" + "case \"$4\" in\n" + // The dispatch case-folds --repo because GitHub serves a repo under any casing of its slug, so a + // case-sensitive fake would answer a differently-cased target with an empty list and hide the + // hit real gh returns. for repo, body := range bodies { script += fmt.Sprintf("%s) printf '%s' ;;\n", anyCasingGlob(repo), body) } @@ -246,9 +234,9 @@ func writeFakeGHPRListPerRepo(t *testing.T, bodies map[string]string) { t.Setenv("PATH", bin) } -// anyCasingGlob turns a repo slug into an unquoted sh case pattern matching it in -// any casing. Safe unquoted because a GitHub slug is [A-Za-z0-9._-/] only, so it -// carries no glob metacharacter of its own. +// Turns a repo slug into an unquoted sh case pattern matching it in any casing - a glob rather than +// tr, because PATH is replaced by the fake's own dir alone and no external binary resolves inside +// these scripts. Safe unquoted: a slug is [A-Za-z0-9._-/] only, so it carries no metacharacter. func anyCasingGlob(slug string) string { var b strings.Builder for _, r := range slug { @@ -265,15 +253,14 @@ func anyCasingGlob(slug string) string { return b.String() } -// forkTargets is the target pair a fork project searches with: its own repo, -// where hand pushes the branch, plus the declared upstream the PR is opened on. +// The target pair a fork project searches with: its own repo, where hand pushes the branch, plus +// the declared upstream the PR is opened on. func forkTargets() []PRSearchTarget { return []PRSearchTarget{{Repo: "me/repo"}, {Repo: "up/repo", HeadRepo: "me/repo"}} } -// TestFindPRByBranchFindsUpstreamPRForFork is the atqamz/secondhand#134 -// regression: a fork contribution's PR lives on the declared upstream, so -// searching the project's own repo alone finds nothing. +// The atqamz/secondhand#134 regression: a fork contribution's PR lives on the declared upstream, +// so searching the project's own repo alone finds nothing. func TestFindPRByBranchFindsUpstreamPRForFork(t *testing.T) { writeFakeGHPRListPerRepo(t, map[string]string{ "me/repo": `[]`, @@ -289,10 +276,9 @@ func TestFindPRByBranchFindsUpstreamPRForFork(t *testing.T) { } } -// TestFindPRByBranchIgnoresUpstreamPRFromAnotherFork pins the head-repo filter: -// an upstream carries head refs from every contributor's fork, and gh matches -// --head on the branch name alone, so a same-named branch from a stranger's fork -// comes back from the same search and must not be recorded as this task's PR. +// Pins the head-repo filter: an upstream carries head refs from every contributor's fork, and gh +// matches --head on the branch name alone, so a same-named branch from a stranger's fork comes back +// from the same search and must not be recorded as this task's PR. func TestFindPRByBranchIgnoresUpstreamPRFromAnotherFork(t *testing.T) { writeFakeGHPRListPerRepo(t, map[string]string{ "me/repo": `[]`, @@ -308,10 +294,9 @@ func TestFindPRByBranchIgnoresUpstreamPRFromAnotherFork(t *testing.T) { } } -// TestFindPRByBranchMatchesHeadRepoCaseInsensitively pins the fold: GitHub slugs -// are case-insensitive, and this filter compares gh's canonical casing against a -// slug read from a clone's origin remote, so a differently-cased remote must not -// drop the project's own PR. +// Pins the fold: GitHub slugs are case-insensitive, and this filter compares gh's canonical casing +// against a slug read from a clone's origin remote, so a differently-cased remote must not drop the +// project's own PR. func TestFindPRByBranchMatchesHeadRepoCaseInsensitively(t *testing.T) { writeFakeGHPRListPerRepo(t, map[string]string{ "Up/Repo": `[{"number":7,"url":"https://github.com/up/repo/pull/7","state":"OPEN",` + @@ -326,10 +311,9 @@ func TestFindPRByBranchMatchesHeadRepoCaseInsensitively(t *testing.T) { } } -// TestFindPRByBranchRefusesPRsInTwoRepos proves matches from two searched repos -// resolve through the same tier rule as two in one repo - a fork whose upstream -// also has a branch of that name is where guessing costs the most - and that the -// refusal names the repos, not just PR numbers an operator would have to hunt for. +// Proves matches from two searched repos resolve through the same tier rule as two in one repo - +// a fork whose upstream also has a branch of that name is where guessing costs the most - and that +// the refusal names the repos, not just PR numbers an operator would have to hunt for. func TestFindPRByBranchRefusesPRsInTwoRepos(t *testing.T) { writeFakeGHPRListPerRepo(t, map[string]string{ "me/repo": `[{"number":3,"url":"https://github.com/me/repo/pull/3","state":"OPEN",` + diff --git a/internal/harness/harness.go b/internal/harness/harness.go index 0b8b54a..f7ca8c4 100644 --- a/internal/harness/harness.go +++ b/internal/harness/harness.go @@ -29,10 +29,9 @@ func IsSupported(name string) bool { return supported[name] } -// FirstRunPrompt is one interactive dialog a harness may show before it starts reading the -// brief. Match is checked against the pane's recent scrollback text. Exactly one of Keys and -// Refuse is set: Keys answer the dialog unattended, while a non-empty Refuse marks a dialog -// that is recognized but deliberately left for a human, and its text says why. +// One interactive dialog a harness may show before it starts reading the brief; Match is checked +// against the pane's recent scrollback text. Exactly one of Keys and Refuse is set: Keys answer the +// dialog unattended, a non-empty Refuse leaves it deliberately for a human and its text says why. type FirstRunPrompt struct { Name string Match *regexp.Regexp @@ -40,28 +39,29 @@ type FirstRunPrompt struct { Refuse string } -// FirstRunPrompts is a harness's verified pane signatures. Known are the dialogs whose exact -// wording is catalogued, and Unrecognized is a generic fallback for "some dialog is still on -// screen" that no Known entry matches. Ready is the harness's own startup paint, a secondary -// signal that a pane herdr already reports a running agent on has finished starting; a harness -// with no Ready signature is still confirmed, just by waiting out the settle window. A zero -// value leaves the launch confirmed on agent presence alone, so a harness with no catalogued -// signatures that parks on a dialog is still reported as started - a known, accepted gap, and -// the reason the catalogue matters for every harness added here, not only claude. +// A harness's verified pane signatures. Known are the dialogs whose exact wording is catalogued, +// and Unrecognized is a generic fallback for "some dialog is still on screen" that no Known entry +// matches. type FirstRunPrompts struct { + // The harness's own startup paint, a secondary signal that a pane herdr already reports a running + // agent on has finished starting. A harness with no Ready signature is still confirmed, just by + // waiting out the settle window. Ready *regexp.Regexp Known []FirstRunPrompt Unrecognized *regexp.Regexp } -// firstRunPrompts holds verified signatures per harness. Only claude has been verified against -// a real first run (see cmd/launch.go); every other harness gets the zero value until one is. +// Verified signatures per harness. Only claude has been verified against a real first run (see +// cmd/launch.go); every other harness gets the zero value until one is, which leaves its launch +// confirmed on agent presence alone - one parking on a dialog is still reported as started. var firstRunPrompts = map[string]FirstRunPrompts{ + // Interactive claude gates on first-run dialogs --print skipped, and a fresh worktree path means + // the trust one appears on every spawn, not just a fresh host (SPECS.md, launch templates). + // cmd/launch.go's confirmLaunch clears them per spawn, not leaving them for an operator to notice. Claude: { - // claude's own startup paint: the splash banner, or one of the two composer footer hints - // once the REPL is up (the bypass-mode line replaces the shortcuts hint whenever the - // footer is wide enough to show it). None of these can come from the echoed launch - // command, so matching one means claude itself drew a frame. + // claude's own startup paint: the splash banner, or either composer footer hint once the REPL + // is up (the bypass-mode line replaces the shortcuts hint when the footer is wide enough). + // None can come from the echoed launch command, so a match means claude drew a frame itself. Ready: regexp.MustCompile(`Welcome\s+to\s+Claude\s+Code|\?\s+for\s+shortcuts|bypass\s+permissions\s+on`), Known: []FirstRunPrompt{ { @@ -77,10 +77,9 @@ var firstRunPrompts = map[string]FirstRunPrompts{ Keys: []string{"Down", "Enter"}, }, { - // Nothing to do with the checked-out repo: this is claude's security dialog for - // managed settings this host's organization policy applies to every run, and - // accepting it grants arbitrary code execution and prompt interception. That is - // a trust decision about the host, so hand will not make it for the operator. + // Nothing to do with the checked-out repo: claude's security dialog for managed + // settings this host's org policy applies to every run. Accepting grants arbitrary code + // execution and prompt interception - a host trust decision hand will not make for you. Name: "managed settings", Match: regexp.MustCompile(`Managed\s+settings\s+require\s+approval|Yes,\s+I\s+trust\s+these\s+settings`), Refuse: "this host has managed settings claude requires approval for, which hand will not accept for you; run claude yourself on this host once and accept the managed-settings prompt, then respawn", @@ -93,16 +92,16 @@ var firstRunPrompts = map[string]FirstRunPrompts{ }, } -// FirstRunPromptsFor returns name's verified first-run signatures, or the zero value if name -// has none. +// FirstRunPromptsFor returns name's verified first-run signatures, or the zero value if name has +// none - a known, accepted gap rather than a bug: the catalogue is what makes an unattended launch +// safe, so it matters for every harness added here, not only claude. func FirstRunPromptsFor(name string) FirstRunPrompts { return firstRunPrompts[name] } -// agentDetectionVerified lists the harnesses whose panes herdr has been observed labeling with -// an agent, by running the real binary in a real pane. The others ship a detection manifest -// under herdr's agent-detection state dir, read but never exercised here because no binary for -// them is installed on this host. +// The harnesses whose panes herdr has been observed labeling with an agent, by running the real +// binary in a real pane. The others ship a detection manifest under herdr's agent-detection state +// dir, read but never exercised here because no binary for them is installed on this host. var agentDetectionVerified = map[string]bool{ Claude: true, OpenCode: true, @@ -154,16 +153,13 @@ func CarriesPrompt(name string) bool { return promptCapable[name] } -// Build constructs the shell command that cds into the worktree and launches the harness -// against the brief. Every launch must be interactive, not one-shot: hand send steers a -// running pane, hand watch classifies its lifecycle, and a no-mistakes pipeline drives many -// turns, none of which a one-shot process can do. Flags verified against the installed CLI -// (--help) are used where available - claude and opencode - and this file is the source of -// truth for those two. Codex, Grok, and Pi have no binary available yet, so they fall back to -// the SPECS.md template syntax and must be re-verified for interactive launch, not just -// flag names, once installable. +// Build constructs the shell command that cds into the worktree and launches the harness against +// the brief. Every launch is interactive, never one-shot: hand send steers a running pane, hand +// watch classifies its lifecycle, and a no-mistakes pipeline drives many turns a one-shot cannot. func Build(name string, opts Options) (string, error) { var launch string + // Flags for claude and opencode are verified against the installed CLI's own --help, and this file + // is the source of truth for those two. switch name { case Claude: launch = buildClaude(opts) @@ -181,19 +177,13 @@ func Build(name string, opts Options) (string, error) { return fmt.Sprintf("cd %s && %s", shellQuote(opts.Worktree), launch), nil } -// buildClaude launches claude interactively (no --print) so the pane stays resident for -// hand send and hand watch across a multi-turn no-mistakes pipeline (verified via -// `claude --help`: --model, --effort, and --dangerously-skip-permissions all apply outside -// --print). --dangerously-skip-permissions is required or an unattended worker stalls on a -// permission prompt. CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false suppresses claude's dim -// predicted-next-prompt ghost text, which would otherwise read to a pane-watching supervisor -// as the worker having typed input while actually idle. Interactive claude also gates on -// first-run dialogs that --print skipped (workspace trust, bypass-permissions disclaimer), and -// a fresh worktree path means the trust one appears on every spawn, not just on a fresh host - -// documented under Harness launch templates in SPECS.md. Their signatures live in -// firstRunPrompts above, and cmd/launch.go's confirmLaunch clears them after every -// spawn/promote instead of leaving them for an operator to notice and answer. +// Launches claude interactively - no --print - so the pane stays resident for hand send and hand +// watch across a multi-turn no-mistakes pipeline. Verified via `claude --help`: --model, --effort +// and --dangerously-skip-permissions all apply outside --print. func buildClaude(o Options) string { + // --dangerously-skip-permissions, or an unattended worker stalls on a permission prompt. + // CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false suppresses the dim predicted-next-prompt ghost text, + // which a pane-watching supervisor would otherwise read as typed input under an idle worker. args := []string{"CLAUDE_CODE_ENABLE_PROMPT_SUGGESTION=false", "claude", "--dangerously-skip-permissions"} if o.Model != "" { args = append(args, "--model", shellQuote(o.Model)) @@ -205,6 +195,9 @@ func buildClaude(o Options) string { return strings.Join(args, " ") } +// No binary for codex, grok or pi exists on this host, so these three fall back to SPECS.md's +// template syntax and need re-verifying for interactive launch, not just flag names, once one is +// installable. func buildCodex(o Options) string { return fmt.Sprintf("codex --file %s", shellQuote(o.Brief)) } @@ -217,25 +210,24 @@ func buildPi(o Options) string { return fmt.Sprintf("pi %s", shellQuote(o.Brief)) } -// buildOpenCode uses the bare `opencode` command (verified via `opencode --help`), which opens -// an interactive TUI, rather than `opencode run`, which is explicitly headless and exits after -// one reply. OPENCODE_CONFIG_CONTENT grants blanket tool permission so an unattended worker -// does not stall on a permission prompt. The bare command has no --file flag and no -// effort/variant flag, so the brief path is embedded in --prompt text instead, and Effort is -// not applied here. +// Uses the bare `opencode` command (verified via `opencode --help`), which opens an interactive TUI, +// rather than `opencode run` - that one is explicitly headless and exits after a single reply. func buildOpenCode(o Options) string { + // OPENCODE_CONFIG_CONTENT grants blanket tool permission so an unattended worker does not stall + // on a permission prompt. args := []string{"OPENCODE_CONFIG_CONTENT=" + shellQuote(`{"permission":{"*":"allow"}}`), "opencode"} if o.Model != "" { args = append(args, "--model", shellQuote(o.Model)) } + // The bare command has no --file flag and no effort or variant flag, so the brief path rides in + // the --prompt text and Options.Effort is dropped here rather than passed. args = append(args, "--prompt", shellQuote(briefPrompt(o))) return strings.Join(args, " ") } -// briefPrompt is shared so the wording cannot drift between harnesses. It ends -// with agentsmd.OperatorDecisionRule because a worker runs in a worktree that -// is never under the fleet home, so the home's AGENTS.md never reaches it and -// the launch prompt is the only channel that rule has. +// Shared so the wording cannot drift between harnesses. It ends with agentsmd.OperatorDecisionRule +// because a worker runs in a worktree that is never under the fleet home, so the home's AGENTS.md +// never reaches it and the launch prompt is the only channel that rule has. func briefPrompt(o Options) string { prompt := fmt.Sprintf("Read the brief at %s and carry out the task it describes.", o.Brief) if o.BriefHasFrontMatter { diff --git a/internal/harness/harness_test.go b/internal/harness/harness_test.go index 1f873af..bab189b 100644 --- a/internal/harness/harness_test.go +++ b/internal/harness/harness_test.go @@ -7,9 +7,9 @@ import ( "github.com/atqamz/secondhand/internal/agentsmd" ) -// quotedPrompt is the shell-quoted first message a harness launches with. The -// operator-decision rule is a paragraph of prose owned by internal/agentsmd, so -// exact-match wants build the prompt from it instead of restating it here. +// The shell-quoted first message a harness launches with. The operator-decision rule is a paragraph +// of prose owned by internal/agentsmd, so exact-match wants build the prompt from it instead of +// restating it here. func quotedPrompt(brief string) string { return shellQuote("Read the brief at " + brief + " and carry out the task it describes. " + agentsmd.OperatorDecisionRule) } @@ -65,8 +65,8 @@ func TestBuildClaudeWithModelAndEffort(t *testing.T) { } } -// TestBuildClaudeNeverHeadless guards against a silent regression to --print, -// which would strand hand send and hand watch with no running pane to steer. +// Guards against a silent regression to --print, which would strand hand send and hand watch with +// no running pane to steer. func TestBuildClaudeNeverHeadless(t *testing.T) { got, err := Build(Claude, Options{Worktree: "/tmp/wt", Brief: "/tmp/brief.md"}) if err != nil { @@ -154,8 +154,8 @@ func TestBuildOpenCodeWithModel(t *testing.T) { } } -// TestBuildOpenCodeNeverHeadless guards against a silent regression to -// `opencode run`, which exits after one reply and leaves no pane to steer. +// Guards against a silent regression to `opencode run`, which exits after one reply and leaves no +// pane to steer. func TestBuildOpenCodeNeverHeadless(t *testing.T) { got, err := Build(OpenCode, Options{Worktree: "/tmp/wt", Brief: "/tmp/brief.md"}) if err != nil { @@ -179,13 +179,13 @@ func TestBuildOpenCodeFrontMatterDisclaimer(t *testing.T) { } } -// TestBuildCarriesOperatorDecisionRule pins the only channel that rule has: a -// worker's worktree is never under the fleet home, so the AGENTS.md copy of it -// never reaches the worker. Codex, Grok, and Pi launch with a bare --file and -// no inline prompt, so they are out of reach until one of them is verified. +// Pins the only channel that rule has: a worker's worktree is never under the fleet home, so the +// AGENTS.md copy of it never reaches the worker. func TestBuildCarriesOperatorDecisionRule(t *testing.T) { quoted := shellQuote(agentsmd.OperatorDecisionRule) escaped := quoted[1 : len(quoted)-1] + // Codex, Grok and Pi launch with a bare --file and no inline prompt, so they are out of reach + // until one of them is verified. for _, name := range []string{Claude, OpenCode} { got, err := Build(name, Options{Worktree: "/tmp/wt", Brief: "/tmp/brief.md"}) if err != nil { @@ -261,9 +261,9 @@ func TestShellQuoteEscapesSingleQuotes(t *testing.T) { } } -// TestFirstRunPromptsClaude pins the shape confirmLaunch depends on: a startup signature for -// each frame claude settles into, answerable dialogs carrying keys, and the managed-settings -// dialog catalogued as recognized-but-refused so it fails fast instead of looking uncatalogued. +// Pins the shape confirmLaunch depends on: a startup signature for each frame claude settles into, +// answerable dialogs carrying keys, and the managed-settings dialog catalogued as +// recognized-but-refused so it fails fast instead of looking uncatalogued. func TestFirstRunPromptsClaude(t *testing.T) { prompts := FirstRunPromptsFor(Claude) if prompts.Ready == nil || prompts.Unrecognized == nil { @@ -305,8 +305,8 @@ func TestFirstRunPromptsClaude(t *testing.T) { } } -// TestAgentDetectionVerified pins the two harnesses actually run in a real pane and observed -// being labeled by herdr; the rest must stay false until each is exercised the same way. +// Pins the two harnesses actually run in a real pane and observed being labeled by herdr; the rest +// must stay false until each is exercised the same way. func TestAgentDetectionVerified(t *testing.T) { for _, name := range []string{Claude, OpenCode} { if !AgentDetectionVerified(name) { diff --git a/internal/harness/usagelimit.go b/internal/harness/usagelimit.go index 765d4b3..788a68f 100644 --- a/internal/harness/usagelimit.go +++ b/internal/harness/usagelimit.go @@ -7,34 +7,24 @@ import ( "time" ) -// usageLimit is a harness's signature for its own usage-limit stop: the harness has -// refused the turn because the account is out of quota, and the pane goes quiet with -// the refusal on screen. Match recognizes that refusal; Reset reads the instant the -// harness predicts the quota returns out of the text from the freshest refusal -// onwards, and reports false when that refusal names none. -// -// A reset instant is only ever a prediction, so nothing decides from it whether the -// limit is over - it decides only when to start trying. See internal/watcher's -// usagelimit.go for what does the deciding. +// A harness's signature for its own usage-limit stop: the harness has refused the turn because the +// account is out of quota, and the pane goes quiet with the refusal on screen. type usageLimit struct { Match *regexp.Regexp + // Reads the instant the harness predicts the quota returns, out of the text from the freshest + // refusal onwards, and reports false when that refusal names none. Only ever a prediction, so + // nothing decides the limit is over from it - see internal/watcher's usagelimit.go for that. Reset func(text string, now time.Time) (time.Time, bool) } -// usageLimits is the per-harness catalogue, and the reason adding a harness here is -// an implementation rather than one more branch in the poll loop. Only claude has a -// signature: its wordings below are the ones its own limit paths emit, and every -// other harness declines the capability until someone catalogues its refusal against -// a real limited run - the same bar firstRunPrompts holds. +// The per-harness catalogue, and the reason adding a harness here is an implementation rather than one +// more branch in the poll loop. Only claude has a signature; every other harness declines the +// capability until someone catalogues its refusal against a real limited run, the bar firstRunPrompts holds. var usageLimits = map[string]usageLimit{ Claude: { - // Three wordings, all of which claude has shipped: the interactive REPL's - // "Claude usage limit reached. Your limit will reset at 3pm (UTC).", the - // machine-readable "Claude AI usage limit reached|", and the - // per-window forms ("5-hour limit reached", "you've reached your weekly - // limit for Opus"). Deliberately anchored on the quota being *reached*, never - // on the word "limit" alone, so claude's own approaching-your-limit warning - - // which does not stop the turn - cannot be read as a stop. + // Three wordings claude has shipped: the REPL's "limit will reset at 3pm (UTC)", the + // machine-readable "usage limit reached|", and the per-window "5-hour limit reached". + // Anchored on *reached*, never "limit" alone, so an approaching-your-limit warning cannot read as a stop. Match: regexp.MustCompile(`(?i)usage limit reached|\d+-hour limit reached|reached your (?:\w+ )*limit`), Reset: parseClaudeReset, }, @@ -47,14 +37,9 @@ func SupportsUsageLimit(name string) bool { return usageLimits[name].Match != nil } -// DetectUsageLimit reports whether text - a pane's recent scrollback - shows name's -// harness stopped on a usage limit, plus the reset instant the message names if it -// names one at all. A harness with no catalogued signature never reports a limit. -// -// Scrollback holds every refusal the harness has printed, not only the one that -// stopped the current turn, so the reset is read from the last match onwards: an -// earlier refusal names a reset that has already come and gone, and reading it would -// schedule the next attempt off a prediction the harness has itself superseded. +// DetectUsageLimit reports whether text - a pane's recent scrollback - shows name's harness stopped on +// a usage limit, plus the reset instant the message names if it names one at all. A harness with no +// catalogued signature never reports a limit. func DetectUsageLimit(name, text string, now time.Time) (time.Time, bool) { limit := usageLimits[name] if limit.Match == nil { @@ -67,6 +52,9 @@ func DetectUsageLimit(name, text string, now time.Time) (time.Time, bool) { if limit.Reset == nil { return time.Time{}, true } + // Scrollback holds every refusal the harness has printed, not only the one that stopped this turn, + // so the reset is read from the last match onwards: an earlier refusal names a reset already come + // and gone, and reading it would schedule off a prediction the harness has itself superseded. reset, ok := limit.Reset(text[found[len(found)-1][0]:], now) if !ok { return time.Time{}, true @@ -79,14 +67,9 @@ var ( claudeResetClock = regexp.MustCompile(`(?i)reset(?:s|ting)?(?:\s+at)?\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\s*(?:\(([^)\n]{1,40})\))?`) ) -// parseClaudeReset reads the reset instant out of whichever wording carried it. The -// epoch form is unambiguous and is tried first; the clock form names an hour in a -// zone that may or may not be this host's, and resolves to the next occurrence of -// that clock time, since a limit's reset is always ahead of the message announcing -// it. An unparseable or unloadable zone falls back to this host's rather than -// failing: a wait computed in the wrong zone is still a bounded wait, and the -// attempt-and-observe loop is what decides the limit is actually over. +// Reads the reset instant out of whichever wording carried it. func parseClaudeReset(text string, now time.Time) (time.Time, bool) { + // The epoch form is unambiguous, so it is tried first. if m := claudeResetEpoch.FindStringSubmatch(text); m != nil { secs, err := strconv.ParseInt(m[1], 10, 64) if err != nil { @@ -123,6 +106,9 @@ func parseClaudeReset(text string, now time.Time) (time.Time, bool) { return time.Time{}, false } + // The clock form names an hour in a zone that may or may not be this host's, and an unparseable or + // unloadable one falls back to this host's rather than failing: a wait computed in the wrong zone is + // still a bounded wait, and the attempt-and-observe loop is what decides the limit is really over. loc := now.Location() if m[4] != "" { if named, err := time.LoadLocation(strings.TrimSpace(m[4])); err == nil { @@ -131,6 +117,8 @@ func parseClaudeReset(text string, now time.Time) (time.Time, bool) { } local := now.In(loc) reset := time.Date(local.Year(), local.Month(), local.Day(), hour, minute, 0, 0, loc) + // A limit's reset is always ahead of the message announcing it, so an hour already past today + // resolves to the next occurrence of that clock time. if !reset.After(now) { reset = reset.AddDate(0, 0, 1) } diff --git a/internal/herdr/client.go b/internal/herdr/client.go index 8e7f58c..37d3f4f 100644 --- a/internal/herdr/client.go +++ b/internal/herdr/client.go @@ -11,10 +11,9 @@ import ( "time" ) -// ErrComposerBusyTimeout marks WaitComposerEmpty's own deadline expiry. A -// caller has to tell it apart from the PaneGet failures the same function -// returns: a composer that stayed busy is transient and worth retrying, a pane -// that stopped answering means no retry can ever succeed. +// ErrComposerBusyTimeout marks WaitComposerEmpty's own deadline expiry, which a caller has to tell +// apart from the PaneGet failures the same function returns: a composer that stayed busy is +// transient and worth retrying, a pane that stopped answering will never answer a retry. var ErrComposerBusyTimeout = errors.New("composer still busy") type Client struct{} @@ -23,14 +22,13 @@ func NewClient() *Client { return &Client{} } -// sanitizedEnvKeys are the harness-identity variables a pane must never inherit from the herdr -// server it's a child of. A server started from inside a Claude Code session carries these in its -// own environment, so every pane it spawns afterwards inherits its parent's session identity and, -// via CLAUDE_CODE_CHILD_SESSION, silently disables its own transcript - the only independent record -// of what a worker did (atqamz/secondhand#109). Blanking them at creation removes the failure for -// every pane hand creates rather than depending on the server's environment being clean. +// The harness-identity variables a pane must never inherit from the herdr server it is a child of +// (atqamz/secondhand#109). A server inside a Claude Code session passes its own session identity +// down, and CLAUDE_CODE_CHILD_SESSION disables the pane's transcript - a worker's only record. var sanitizedEnvKeys = []string{"CLAUDE_CODE_CHILD_SESSION", "CLAUDE_CODE_SESSION_ID", "CLAUDECODE"} +// Blanking them at creation removes the failure for every pane hand creates, rather than depending +// on the herdr server's own environment being clean. func sanitizedEnvArgs() []string { args := make([]string, 0, len(sanitizedEnvKeys)*2) for _, key := range sanitizedEnvKeys { @@ -49,8 +47,8 @@ type envelope struct { Error *errorBody `json:"error"` } -// run execs herdr and returns its trimmed stdout and trimmed stderr alongside -// the process error, letting call and callVoid share one invocation path. +// Execs herdr and returns its trimmed stdout and trimmed stderr alongside the process error, +// letting call and callVoid share one invocation path. func (c *Client) run(args ...string) ([]byte, string, error) { cmd := exec.Command("herdr", args...) var stdout, stderr bytes.Buffer @@ -68,8 +66,8 @@ func parseEnvelope(args []string, trimmed []byte) (envelope, error) { return env, nil } -// call is for query commands, whose real herdr response is always a JSON -// envelope carrying a non-null result object. +// For query commands, whose real herdr response is always a JSON envelope carrying a non-null +// result object. func (c *Client) call(args ...string) (json.RawMessage, error) { trimmed, stderr, runErr := c.run(args...) @@ -103,10 +101,9 @@ func (c *Client) call(args ...string) (json.RawMessage, error) { return result, nil } -// callVoid is for void commands (pane run/send-text/send-keys), whose real -// herdr response is empty stdout on success and a JSON error envelope - with -// exit code 0 - on failure, so the error envelope must be checked ahead of -// (and independent from) the process exit status. +// For void commands (pane run/send-text/send-keys), whose real herdr response is empty stdout on +// success and a JSON error envelope - with exit code 0 - on failure, so the envelope must be +// checked ahead of, and independent from, the process exit status. func (c *Client) callVoid(args ...string) error { trimmed, stderr, runErr := c.run(args...) @@ -182,11 +179,9 @@ func (c *Client) WorkspaceCreate(cwd, label string) (Workspace, Tab, Pane, error Tab Tab `json:"tab"` RootPane Pane `json:"root_pane"` } - // A workspace ID on any failure path below means herdr already created the workspace before - // the response came back unusable - reachable only against a herdr whose protocol predates - // the tab/root_pane fields - so it must be closed here, before the parse error leaves this - // function, or nothing else will ever learn the workspace exists to clean it up. Unmarshal - // keeps decoding past its first type error, so a partly-decoded body carries an ID too. + // A workspace ID on a failure path below means herdr created the workspace before the response + // came back unusable (only reachable against a protocol predating tab/root_pane), so it closes + // here or nothing ever learns it exists. Unmarshal decodes past its first type error, ID too. failed := func(parseErr error) (Workspace, Tab, Pane, error) { if body.Workspace.WorkspaceID != "" { if closeErr := c.WorkspaceClose(body.Workspace.WorkspaceID); closeErr != nil { @@ -304,25 +299,18 @@ func (c *Client) PaneSendKeys(paneID string, keys ...string) error { return c.callVoid(args...) } -// PaneRead returns the pane's recent scrollback as plain text. Its one caller looks for first-run -// dialogs, and the answerable part of a dialog is its lower half - claude's trust dialog is only -// recognizable by its "Yes, I trust this folder" option and the generic fallback only by the -// "Enter to confirm" footer - so a viewport too short to hold the whole dialog clips exactly the -// text that has to match. That is not hypothetical: a pane measures 23 rows in an unattached herdr -// session against 61 in an attached one, and hand spawns headlessly with nothing attached. -// --source visible was tried for this call and reverted for that reason; a clipped dialog matches -// nothing, and an unmatched dialog under a live agent is confirmed as started, so the short pane -// fails silently and wrongly. Re-answering a dialog whose text lingers in scrollback is prevented -// in cmd/launch.go instead, by answering each catalogued dialog at most once per launch. -// -// Unlike every command above, herdr's own contract for pane read is a third shape: raw text on -// success, and on failure a bare {"code","message"} object rather than the {"error":{...}} envelope -// call and callVoid expect. That body is checked ahead of the exit status for the same reason -// callVoid checks its envelope first - herdr's exit code cannot be trusted on its own - and here a -// failure read as pane text would confirm a worker no one observed. +// PaneRead returns the pane's recent scrollback as plain text, and a failure as an error rather than as +// text, since a failure read as pane text would confirm a worker nobody observed. Re-answering a dialog +// that lingers in scrollback is prevented in cmd/launch.go, by answering each one once per launch. func (c *Client) PaneRead(paneID string, lines int) (string, error) { + // The one caller matches first-run dialogs on their lower half, so a viewport too short for the whole + // dialog clips exactly the text that has to match, and an unmatched dialog under a live agent reads as + // started. Hence recent, not visible's 23-row unattached viewport (internal/faketool/FIDELITY.md). args := []string{"pane", "read", paneID, "--source", "recent", "--lines", strconv.Itoa(lines)} stdout, stderr, runErr := c.run(args...) + // Unlike every command above, herdr's contract for pane read is a third shape: raw text on + // success, on failure a bare {"code","message"} object, not the {"error":{...}} envelope. Read + // ahead of the exit status for callVoid's reason - that code cannot be trusted on its own. var eb errorBody if json.Unmarshal(stdout, &eb) == nil && eb.Code != "" && eb.Message != "" { return "", fmt.Errorf("herdr %s: %s: %s", strings.Join(args, " "), eb.Code, eb.Message) diff --git a/internal/herdr/client_test.go b/internal/herdr/client_test.go index 71317b2..55b93fa 100644 --- a/internal/herdr/client_test.go +++ b/internal/herdr/client_test.go @@ -9,16 +9,9 @@ import ( "time" ) -// writeFakeHerdr fakes herdr with the caller's own script body, so each test -// below picks the response shape it needs. Real herdr answers a query command -// with a JSON envelope carrying a non-null result object on stdout, answers a -// void command with empty stdout, and reports failure as an envelope error -// object that may come with any exit status - which is why call/callVoid -// (client.go) let an error envelope win whenever one is present and fall back -// to the exit status only when stdout is empty or the envelope parsed clean -// (env.Error == nil). The tests here fake each of those shapes verbatim, -// including both error-envelope-with-exit-1 and error-envelope-with-exit-0; -// other packages' herdr fakes cite this file rather than re-deriving them. +// Fakes herdr with the caller's own script body, so each test picks the response shape it needs. +// Every real shape is reproduced verbatim - a query's non-null result envelope, a void command's +// empty stdout, an error envelope at exit 1 and at exit 0 - and other packages cite these. func writeFakeHerdr(t *testing.T, script string) { t.Helper() bin := t.TempDir() @@ -65,10 +58,9 @@ func TestFindWorkspaceByLabelNotFound(t *testing.T) { } } -// TestWorkspaceCreateParsesRootTabAndPane pins the fix for the orphan-tab bug: herdr always -// creates a root tab and pane at cwd as a side effect of creating a workspace, so -// WorkspaceCreate must parse and return them rather than discarding them - a caller that then -// creates a second tab for its task leaves the root tab behind as an unowned live shell. +// Pins the fix for the orphan-tab bug: herdr creates a root tab and pane at cwd as a side effect +// of creating a workspace, so WorkspaceCreate must return them rather than discard them - a caller +// that then creates its own tab leaves the root tab behind as an unowned live shell. func TestWorkspaceCreateParsesRootTabAndPane(t *testing.T) { writeFakeHerdr(t, `printf '{"id":"cli:1","result":{"workspace":{"workspace_id":"wA","label":"proj","tab_count":1},"tab":{"tab_id":"wA:tB","workspace_id":"wA","label":"1"},"root_pane":{"pane_id":"wA:pC","tab_id":"wA:tB","agent_status":"idle"}}}'`) c := NewClient() @@ -87,11 +79,9 @@ func TestWorkspaceCreateParsesRootTabAndPane(t *testing.T) { } } -// TestWorkspaceCreateSanitizesInheritedHarnessMarkers pins the fix for atqamz/secondhand#109: a -// pane is a child of the herdr server, so it otherwise inherits any CLAUDE_CODE_CHILD_SESSION, -// CLAUDE_CODE_SESSION_ID, or CLAUDECODE the server itself was started under, silently disabling -// the worker's transcript. WorkspaceCreate must blank all three on every pane it creates, -// regardless of whether the server actually carries them. +// Pins the fix for atqamz/secondhand#109: a pane is a child of the herdr server, so it otherwise +// inherits any CLAUDE_CODE_CHILD_SESSION, CLAUDE_CODE_SESSION_ID, or CLAUDECODE the server was +// started under, silently killing the worker's transcript. All three are blanked either way. func TestWorkspaceCreateSanitizesInheritedHarnessMarkers(t *testing.T) { writeFakeHerdr(t, ` echo "$@" >> "$HERDR_CALL_LOG" @@ -124,11 +114,9 @@ func TestWorkspaceCreateRejectsMissingRootTabOrPane(t *testing.T) { } } -// TestWorkspaceCreateClosesWorkspaceOnPartialResponse pins the fix for atqamz/secondhand#74: a -// workspace_created result missing tab or root_pane still means herdr already created the -// workspace (reachable against a herdr whose protocol predates those fields), so WorkspaceCreate -// must close it itself before the parse error reaches the caller - nothing downstream ever learns -// the workspace ID otherwise. +// Pins the fix for atqamz/secondhand#74: a workspace_created result missing tab or root_pane still +// means herdr created the workspace (a protocol predating those fields), so WorkspaceCreate closes +// it before the parse error reaches the caller - nothing downstream learns the ID. func TestWorkspaceCreateClosesWorkspaceOnPartialResponse(t *testing.T) { writeFakeHerdr(t, ` echo "$@" >> "$HERDR_CALL_LOG" @@ -166,9 +154,9 @@ esac } } -// TestWorkspaceCreateClosesWorkspaceOnMalformedResponse covers the sibling of the partial-response -// path: encoding/json keeps decoding past its first type error, so a response that types a field -// wrongly still yields a workspace ID herdr has already created and this call must still close. +// Covers the sibling of the partial-response path: encoding/json keeps decoding past its first +// type error, so a response that types a field wrongly still yields a workspace ID herdr has +// already created and this call must still close. func TestWorkspaceCreateClosesWorkspaceOnMalformedResponse(t *testing.T) { writeFakeHerdr(t, ` echo "$@" >> "$HERDR_CALL_LOG" @@ -317,9 +305,9 @@ func TestTabCreateParsesTabAndRootPane(t *testing.T) { } } -// TestTabCreateSanitizesInheritedHarnessMarkers is TabCreate's half of -// TestWorkspaceCreateSanitizesInheritedHarnessMarkers: a task landing in an already-existing -// workspace creates its own tab via TabCreate instead, and that path must be sanitized too. +// TabCreate's half of TestWorkspaceCreateSanitizesInheritedHarnessMarkers: a task landing in an +// already-existing workspace creates its own tab via TabCreate instead, and that path must be +// sanitized too. func TestTabCreateSanitizesInheritedHarnessMarkers(t *testing.T) { writeFakeHerdr(t, ` echo "$@" >> "$HERDR_CALL_LOG" @@ -344,11 +332,9 @@ printf '{"id":"cli:1","result":{"tab":{"tab_id":"wA:tB","workspace_id":"wA"},"ro } } -// TestTabCreateClosesTabOnPartialResponse pins the fix for atqamz/secondhand#119: a tab-create -// result missing root_pane still means herdr already created the tab (reachable against a herdr -// whose protocol predates that field, same as atqamz/secondhand#74's WorkspaceCreate case), so -// TabCreate must close it itself before the parse error reaches the caller - nothing downstream -// ever learns the tab ID otherwise. +// Pins the fix for atqamz/secondhand#119: a tab-create result missing root_pane still means herdr +// created the tab (same as atqamz/secondhand#74's WorkspaceCreate case), so TabCreate closes it +// before the parse error reaches the caller - nothing downstream learns the tab ID. func TestTabCreateClosesTabOnPartialResponse(t *testing.T) { writeFakeHerdr(t, ` echo "$@" >> "$HERDR_CALL_LOG" @@ -386,9 +372,9 @@ esac } } -// TestTabCreateClosesTabOnMalformedResponse covers the sibling of the partial-response path: -// encoding/json keeps decoding past its first type error, so a response that types a field wrongly -// still yields a tab ID herdr has already created and this call must still close it. +// Covers the sibling of the partial-response path: encoding/json keeps decoding past its first +// type error, so a response that types a field wrongly still yields a tab ID herdr has already +// created and this call must still close it. func TestTabCreateClosesTabOnMalformedResponse(t *testing.T) { writeFakeHerdr(t, ` echo "$@" >> "$HERDR_CALL_LOG" @@ -479,9 +465,8 @@ func TestTabListParsesResult(t *testing.T) { } } -// TestPaneReadReadsRecentScrollback pins --source recent: a 23-row unattached pane clips the option -// and footer lines that identify a first-run dialog, and a dialog that matches nothing is confirmed -// as a started worker. +// Pins --source recent: a 23-row unattached pane clips the option and footer lines that identify a +// first-run dialog, and a dialog that matches nothing is confirmed as a started worker. func TestPaneReadReadsRecentScrollback(t *testing.T) { writeFakeHerdr(t, `case "$*" in *"--source recent"*) printf 'Welcome to Claude Code\n' ;; @@ -497,9 +482,8 @@ esac`) } } -// TestPaneReadRejectsErrorBodyOnExitZero pins that the bare {code,message} failure body is -// honored even when herdr exits 0: read as pane text it would look like a dialog-free pane and -// confirm a worker no one ever observed. +// Pins that the bare {code,message} failure body is honored even when herdr exits 0: read as pane +// text it would look like a dialog-free pane and confirm a worker no one ever observed. func TestPaneReadRejectsErrorBodyOnExitZero(t *testing.T) { writeFakeHerdr(t, `printf '{"code":"pane_not_found","message":"no such pane"}'; exit 0`) c := NewClient() diff --git a/internal/herdr/types.go b/internal/herdr/types.go index dbd68fe..a195a02 100644 --- a/internal/herdr/types.go +++ b/internal/herdr/types.go @@ -3,22 +3,18 @@ // SPECS.md's herdr examples should match this code, not the other way around. package herdr -// Status is herdr's agent_status value for a pane. herdr's real vocabulary has -// five values, not four: idle, working, blocked, done, unknown. -// -// done is not a task-outcome signal. herdr derives it from its own seen/notification -// bookkeeping: a working-or-blocked pane that goes idle is reported as idle only if a -// live, OS-focused herdr client currently has that pane's tab active at the instant of -// the transition; otherwise it's reported as done. hand polls the API and never -// focuses a client on worker panes, so it observes done, essentially always, for this -// transition - never idle. Treat idle and done as the same signal ("pane stopped being -// busy") and use NotBusy to test for either. +// Status is herdr's agent_status value for a pane. The real vocabulary has five values, not four: +// idle, working, blocked, done, unknown - and idle and done are one signal, "the pane stopped +// being busy", which NotBusy is the way to test for. type Status string const ( StatusIdle Status = "idle" StatusWorking Status = "working" StatusBlocked Status = "blocked" + // Not a task outcome: herdr derives it from its own seen/notification bookkeeping, reporting a + // working-or-blocked pane that goes idle as idle only while a live, OS-focused client has that + // pane's tab active. hand polls headlessly, so it observes done for that transition, not idle. StatusDone Status = "done" StatusUnknown Status = "unknown" ) @@ -42,10 +38,9 @@ type Tab struct { Label string `json:"label"` } -// Agent names the harness herdr detects running in the pane, and is empty when the pane holds -// no agent - a bare shell, or one whose harness has exited. AgentStatus is "unknown" in that -// case, but it is also "unknown" for a pane herdr has not classified yet, so Agent is the field -// to test for a running harness. +// Agent names the harness herdr detects in the pane, empty when the pane holds no agent - a bare +// shell, or one whose harness exited. AgentStatus is "unknown" then, but also for a pane herdr has +// not classified yet, so Agent is the field to test for a running harness. type Pane struct { PaneID string `json:"pane_id"` TabID string `json:"tab_id"` diff --git a/internal/home/home.go b/internal/home/home.go index be24ea5..8154042 100644 --- a/internal/home/home.go +++ b/internal/home/home.go @@ -15,24 +15,14 @@ import ( // sentence on its own, so callers should not add more context around it. var ErrNotFound = errors.New("not inside a secondhand home; run `hand init` or set HAND_HOME") -// ErrHandHomeInvalid is wrapped into the error Resolve returns when HAND_HOME -// is set but does not name a fleet home. It is separate from ErrNotFound -// because the remedy differs: an operator who already set HAND_HOME is not -// helped by being told to set it. +// ErrHandHomeInvalid is wrapped into Resolve's error when HAND_HOME is set but does +// not name a fleet home. Separate from ErrNotFound because the remedy differs: an +// operator who already set HAND_HOME is not helped by being told to set it. var ErrHandHomeInvalid = errors.New("is not a secondhand home; check the path or unset HAND_HOME to search up from the working directory") -// IsHome reports whether dir is a fleet home, the one definition every caller -// (the resolver, hand init, and agentsmd's refresh) shares. The marker is -// state/hand.db, which only hand ever creates (hand init writes it up front; -// every command that touches machine state recreates it if missing), rather -// than the state/ directory itself: project clones live at -// /projects/, so a clone carrying its own generic top-level -// data/ and state/ directories would otherwise stop the ancestor walk short -// and be dispatched into as the home. A home initialized before hand.db -// existed falls back to the marker it was initialized with, data/projects.md -// plus state/, so an operator upgrading in place never has to re-run -// anything by hand and the legacy state/.json import (see -// internal/store's migrateLegacy) still finds a home to run against. +// IsHome reports whether dir is a fleet home, the one definition the resolver, hand +// init and agentsmd's refresh share. The marker is state/hand.db, not state/ itself: a +// clone under projects/ with generic data/ and state/ would be dispatched into as home. func IsHome(dir string) (bool, error) { for _, markers := range markerSets { ok, err := matchesMarkers(dir, markers) @@ -48,6 +38,9 @@ type homeMarker struct { isDir bool } +// hand.db is only ever created by hand: hand init writes it up front and every command +// touching machine state recreates it. The second set is the pre-hand.db marker, so an +// older home upgrades in place and internal/store's migrateLegacy still finds it. var markerSets = [][]homeMarker{ { {"state", true}, @@ -60,10 +53,9 @@ var markerSets = [][]homeMarker{ }, } -// matchesMarkers relies on every marker set listing a directory ahead of the -// markers nested under it, so a parent that turned out to be a plain file is -// rejected before the nested stat, which would otherwise fail with "not a -// directory" instead of the not-exist the caller treats as a clean no-match. +// Relies on every marker set listing a directory ahead of the markers nested under it, +// so a parent that turned out to be a plain file is rejected before the nested stat - +// which would fail with "not a directory" instead of the not-exist read as no-match. func matchesMarkers(dir string, markers []homeMarker) (bool, error) { for _, m := range markers { info, err := os.Stat(filepath.Join(dir, m.rel)) @@ -80,17 +72,14 @@ func matchesMarkers(dir string, markers []homeMarker) (bool, error) { return true, nil } -// Resolve finds the fleet home a command should run against: HAND_HOME if -// set, otherwise the nearest ancestor of the working directory (including -// the working directory itself) that IsHome reports true for. HAND_HOME set -// to a directory that isn't a home fails loudly instead of silently falling -// back to the walk-up, since a silent fallback is how an operator ends up -// dispatching into the wrong fleet. The returned path is always absolute: -// commands derive paths they hand to subprocesses running elsewhere from it, -// and a relative HAND_HOME would otherwise name a different home per -// working directory. +// Resolve finds the fleet home a command runs against: HAND_HOME if set, else the nearest +// ancestor of the working directory (itself included) that IsHome accepts. A HAND_HOME that +// is not a home fails loudly, because a silent walk-up dispatches into the wrong fleet. func Resolve() (string, error) { if handHome := os.Getenv("HAND_HOME"); handHome != "" { + // Absolute, because commands derive the paths they hand to subprocesses running + // elsewhere from this, and a relative HAND_HOME would name a different home for + // every working directory it is read from. handHome, err := filepath.Abs(handHome) if err != nil { return "", fmt.Errorf("resolve HAND_HOME: %w", err) diff --git a/internal/home/home_test.go b/internal/home/home_test.go index 056db4e..34ec6b9 100644 --- a/internal/home/home_test.go +++ b/internal/home/home_test.go @@ -8,7 +8,7 @@ import ( "testing" ) -// makeHome builds a home carrying the marker IsHome checks, state/hand.db. +// Builds a home carrying the marker IsHome checks, state/hand.db. func makeHome(t *testing.T, dir string) { t.Helper() if err := os.MkdirAll(filepath.Join(dir, "state"), 0o755); err != nil { @@ -19,8 +19,8 @@ func makeHome(t *testing.T, dir string) { } } -// makeGenericDataAndState builds what an unrelated project clone can plausibly -// have at its top level, which must not be mistaken for a fleet home. +// Builds what an unrelated project clone can plausibly have at its top level, +// which must not be mistaken for a fleet home. func makeGenericDataAndState(t *testing.T, dir string) { t.Helper() if err := os.MkdirAll(filepath.Join(dir, "data"), 0o755); err != nil { @@ -31,9 +31,9 @@ func makeGenericDataAndState(t *testing.T, dir string) { } } -// makeLegacyHome builds a home initialized before state/hand.db existed: the -// data/projects.md plus state/ marker a pre-sqlite hand init wrote, and the -// one migrateLegacy needs recognized as a home before it can ever run. +// Builds a home initialized before state/hand.db existed: the data/projects.md plus +// state/ marker a pre-sqlite hand init wrote, and the one migrateLegacy needs +// recognized as a home before it can ever run. func makeLegacyHome(t *testing.T, dir string) { t.Helper() if err := os.MkdirAll(filepath.Join(dir, "data"), 0o755); err != nil { @@ -258,10 +258,9 @@ func TestResolvePrefersHandHomeOverCwd(t *testing.T) { } } -// A relative HAND_HOME names one fleet home, not a different one per working -// directory: commands join paths onto the resolved home and hand them to -// subprocesses that run somewhere else entirely (hand spawn's brief path, which -// the harness reads from inside the worktree). +// A relative HAND_HOME names one fleet home, not a different one per working directory: +// commands join paths onto the resolved home and hand them to subprocesses running +// elsewhere (hand spawn's brief path, which the harness reads inside the worktree). func TestResolveAbsolutizesARelativeHandHome(t *testing.T) { parent := t.TempDir() home := filepath.Join(parent, "fleet") diff --git a/internal/notify/notify.go b/internal/notify/notify.go index 6d1ba32..8522fbf 100644 --- a/internal/notify/notify.go +++ b/internal/notify/notify.go @@ -14,16 +14,14 @@ import ( "time" ) -// ErrNotConfigured means home has no config/notify template, or has an empty -// one, so Send has nothing to run and delivers nothing. Callers must not treat -// this as success: the one property this package exists to protect is that "not -// configured" and "delivered" are never the same observable outcome. +// ErrNotConfigured means home has no config/notify template, or an empty one, so +// Send runs nothing. Callers must not treat it as success: this package exists to +// keep "not configured" and "delivered" from being the same observable outcome. var ErrNotConfigured = errors.New("no config/notify") -// sendTimeout bounds the template's own run. The watcher calls Send inline in -// its poll loop, so an unbounded template - the documented example is a bare -// curl with no --max-time - would wedge polling, --until-event's timeout and -// shutdown alike. A var so tests can shorten it. +// Bounds the template's own run: the watcher calls Send inline in its poll loop, +// so an unbounded template (the documented curl example has no --max-time) would +// wedge polling, --until-event's timeout and shutdown alike. A var so tests cut it. var sendTimeout = 10 * time.Second // Send runs config/notify's shell command template with message available as @@ -52,10 +50,9 @@ func Send(home, message string) error { run.WaitDelay = time.Second out, err := run.CombinedOutput() if err != nil { - // WaitDelay only bounds how long Send waits on a pipe an orphaned - // grandchild still holds ("... &" templates); the template's own process - // already exited 0, so the send happened. A real failure is a non-zero - // exit code, reported ahead of ErrWaitDelay. + // WaitDelay only bounds waiting on a pipe an orphaned grandchild still + // holds ("... &" templates), where the template's own process exited 0 and + // the send happened. A real failure is a non-zero exit, reported ahead of it. if errors.Is(err, exec.ErrWaitDelay) && run.ProcessState != nil && run.ProcessState.Success() { return nil } diff --git a/internal/project/gaterun.go b/internal/project/gaterun.go index da0a200..14194f6 100644 --- a/internal/project/gaterun.go +++ b/internal/project/gaterun.go @@ -7,32 +7,18 @@ import ( "strings" ) -// gateRunLimit bounds how far back `no-mistakes runs` is asked to look. Large enough to cover any -// project's real history (the busiest project here sits under 30 runs total) without asking for +// Bounds how far back `no-mistakes runs` is asked to look. Large enough to cover any project's +// real history (the busiest project here sits under 30 runs total) without asking for // literally unbounded output on every check. const gateRunLimit = "10000" -// GateRunPRs returns the set of PR URLs recorded by completed no-mistakes runs in clonePath, read -// from `no-mistakes runs` text - the same read-only, text-scraping approach GateStatus already uses, -// never `~/.no-mistakes/state.sqlite` directly. It answers per clone rather than per PR so a caller -// checking many tasks on one project pays one no-mistakes process, not one per task. -// -// Membership establishes only that the no-mistakes `pr` step opened that exact PR from a run that -// reached `completed`. It is deliberately not a per-commit answer: no-mistakes' own state is keyed on -// working_path, not per-PR (see SPECS.md's "Gate preflight"), and hand records no head commit for a -// task's PR to compare against. A push to the same branch after the matched run completed - amending -// the PR without a new run - reads as gated here exactly as it did before that push; that gap is real -// and is documented rather than papered over with a false confidence this data cannot support. A PR -// opened by hand outside the no-mistakes `pr` step, even behind a run that did complete, also reads -// as absent, for the same reason: nothing ties that URL to that run's own bookkeeping. -// -// Every way of failing to ask no-mistakes at all is an error, never an empty set, so a caller can -// keep "the gate recorded no such run" separate from "the question could not be asked": a missing -// clone path, an unrunnable binary, and - read from the output text rather than the exit code, the -// same way GateStatus reads them, because `no-mistakes runs` exits 1 for both an uninitialized gate -// and a non-git clone path, leaving the exit code with nothing to tell the two apart - an -// uninitialized gate or a clone path that is not a git repository at all. +// GateRunPRs returns the PR URLs recorded by completed no-mistakes runs in clonePath, scraped +// from `no-mistakes runs` text the way GateStatus does, never out of `~/.no-mistakes` directly. +// Per clone rather than per PR, so many tasks on one project pay one no-mistakes process. func GateRunPRs(clonePath string) (map[string]bool, error) { + // Every way of failing to ask no-mistakes at all is an error, never an empty set, so a + // caller can keep "the gate recorded no such run" separate from "the question could not be + // asked". if _, err := os.Stat(clonePath); err != nil { return nil, fmt.Errorf("no-mistakes clone path: %w", err) } @@ -40,6 +26,9 @@ func GateRunPRs(clonePath string) (map[string]bool, error) { cmd.Dir = clonePath out, err := cmd.CombinedOutput() text := string(out) + // Both read out of the output text rather than the exit code, the way GateStatus reads + // them: `no-mistakes runs` exits 1 for an uninitialized gate and for a non-git clone path + // alike, leaving the exit code with nothing to tell the two apart. if strings.Contains(text, gateNotInitializedMarker) { return nil, fmt.Errorf("no-mistakes gate not initialized: %s", GateInitCommand(clonePath)) } @@ -49,12 +38,18 @@ func GateRunPRs(clonePath string) (map[string]bool, error) { if err != nil { return nil, fmt.Errorf("no-mistakes binary not found or not runnable: %w", err) } + // Membership establishes only that the `pr` step opened that exact PR from a run that + // reached completed, not a per-commit answer: no-mistakes keys its state on working_path + // (SPECS.md's "Gate preflight") and hand records no head commit to compare against. prs := make(map[string]bool) for _, line := range strings.Split(text, "\n") { fields := strings.Fields(line) if len(fields) < 2 || fields[0] != "completed" { continue } + // So a push amending the PR after its matched run reads as gated exactly as it did + // before that push, and a PR opened outside the `pr` step reads as absent even behind a + // run that did complete. Both gaps are real, and documented rather than papered over. prs[fields[len(fields)-1]] = true } return prs, nil diff --git a/internal/project/gaterun_test.go b/internal/project/gaterun_test.go index 9bda10f..edd024c 100644 --- a/internal/project/gaterun_test.go +++ b/internal/project/gaterun_test.go @@ -22,9 +22,8 @@ func TestGateRunPRsCollectsCompletedRunPRs(t *testing.T) { } } -// TestGateRunPRsIgnoresNonCompletedRuns covers a run that recorded a PR URL but never reached -// completed - running or failed both leave the gate un-cleared for that commit, so neither should -// count as evidence a run happened. +// Covers a run that recorded a PR URL but never reached completed - running or failed both leave +// the gate un-cleared for that commit, so neither should count as evidence a run happened. func TestGateRunPRsIgnoresNonCompletedRuns(t *testing.T) { fakeNoMistakes(t, " failed 97-gate-visibility 758d72bf 2026-08-03 04:29 https://github.com/atqamz/secondhand/pull/120\n") @@ -61,10 +60,9 @@ func TestGateRunPRsMissingBinary(t *testing.T) { } } -// TestGateRunPRsNotInitializedIsAnError covers the uninitialized gate and the stale renamed -// working_path from atqamz/secondhand#60, which print this text identically. An empty run set here -// would render as the far stronger claim that the PR never went through a gate run, when in truth -// no-mistakes was never asked - the state it would have answered from still holds those runs. +// Covers the uninitialized gate and atqamz/secondhand#60's stale renamed working_path, which print +// this text identically. An empty run set would claim the PR never went through a gate run, when +// in truth no-mistakes was never asked - the state it answers from still holds those runs. func TestGateRunPRsNotInitializedIsAnError(t *testing.T) { fakeNoMistakesExit(t, "repo not initialized (run 'no-mistakes init' first)", 1) diff --git a/internal/project/pr.go b/internal/project/pr.go index f7e9cf7..0f9659b 100644 --- a/internal/project/pr.go +++ b/internal/project/pr.go @@ -11,15 +11,9 @@ import ( "github.com/atqamz/secondhand/internal/state" ) -// ValidatePR is the single gate every PR URL passes before it is recorded on a -// task, whether it came from `hand pr` or from the watcher auto-recording a URL -// a worker embedded in a report line. A recorded PR feeds `gh pr merge` -// directly, so a URL naming a foreign repo must never reach task state. -// -// A fork contribution opens its PR on the project's declared upstream rather -// than on the repo hand pushes to, so that repo passes too - but only because -// an operator declared it (hand project upstream), never because the URL's repo -// happens to look related to the project's own. +// ValidatePR is the single gate every PR URL passes before it is recorded on a task, from +// `hand pr` or from the watcher auto-recording one a worker embedded in a report line. A +// recorded PR feeds `gh pr merge` directly, so a foreign repo must never reach task state. func ValidatePR(ctx context.Context, homeDir string, p Project, url string) error { repoSlug, err := RepoSlug(homeDir, p) if err != nil { @@ -29,12 +23,13 @@ func ValidatePR(ctx context.Context, homeDir string, p Project, url string) erro if !ok { return fmt.Errorf("invalid PR URL %q", url) } - // urlSlug is never empty here, so an undeclared upstream cannot match it. - // EqualFold, not ==: a GitHub slug is case-insensitive and unique only up to - // casing, so folding cannot admit a foreign repo, while comparing exactly - // refuses a landed PR whose canonical casing (what gh reports, what the URL - // carries) differs from the clone's origin remote or the declared upstream. + // urlSlug is never empty here, so an undeclared upstream cannot match it. EqualFold, not + // ==: a GitHub slug is unique only up to casing, so folding cannot admit a foreign repo, + // while == refuses a landed PR whose canonical casing differs from either declared one. if !strings.EqualFold(urlSlug, repoSlug) && !strings.EqualFold(urlSlug, p.Upstream) { + // A fork contribution opens its PR on the declared upstream rather than on the repo hand + // pushes to, so p.Upstream passes too - but only because an operator declared it (hand + // project upstream), never because the URL's repo looks related to the project's own. return fmt.Errorf("PR %s belongs to %s, not project %s's repo (%s)%s", url, urlSlug, p.Name, repoSlug, upstreamNote(p)) } if _, err := ghutil.PRIsMerged(ctx, url); err != nil { @@ -43,10 +38,9 @@ func ValidatePR(ctx context.Context, homeDir string, p Project, url string) erro return nil } -// upstreamNote names the declared upstream in a refusal, and its absence in -// one for a project that has none: an operator whose fork contribution was -// refused has to be able to tell "the upstream I declared is a different repo" -// from "this project declares no upstream at all", which is the remedy. +// Names the declared upstream in a refusal, and its absence in one for a project that has +// none: an operator whose fork contribution was refused has to tell "the upstream I declared +// is a different repo" from "this project declares no upstream at all", which is the remedy. func upstreamNote(p Project) string { if p.Upstream == "" { return " and no upstream is declared for it" @@ -59,10 +53,8 @@ func upstreamNote(p Project) string { // actually operate on. func RepoSlug(homeDir string, p Project) (string, error) { // config --get, not remote get-url: the latter resolves the URL through any - // url..insteadOf rule (e.g. a corporate mirror or ssh-rewrite config) - // before we ever see it, which could turn a genuine mismatch into a false - // match or a false "can't derive repo" refusal. The raw stored value is - // what hand and gh actually need to agree on. + // url..insteadOf rule (a corporate mirror, an ssh rewrite) first, which could turn a + // genuine mismatch into a false match or refusal. hand and gh agree on the stored value. c := exec.Command("git", "config", "--get", "remote.origin.url") c.Dir = filepath.Join(homeDir, "projects", p.Name) out, err := c.Output() diff --git a/internal/project/project.go b/internal/project/project.go index 9db6689..2054062 100644 --- a/internal/project/project.go +++ b/internal/project/project.go @@ -177,16 +177,13 @@ func parseLine(line string) (Project, bool) { return Project{Name: name, URL: url, Mode: mode, Upstream: upstream}, true } -// ParseRepoRef normalizes a repo reference an operator types - a bare -// "owner/repo" or any remote URL form ghutil understands - into the -// "owner/repo" slug the PR guard compares against. It refuses rather than -// guessing: an upstream nobody can resolve to a slug would widen the guard to -// whatever the comparison happened to fall through to. -// A slug containing whitespace is refused outright rather than stored: the -// registry projection writes it as a whitespace-separated upstream= field, -// which parseLine would read back truncated and then reject as an invalid -// registry line, breaking every project command against a rebuilt db. +// ParseRepoRef normalizes a repo reference an operator types - a bare "owner/repo" or any +// remote URL form ghutil understands - into the slug the PR guard compares against. It refuses +// rather than guessing: an upstream nobody can resolve to a slug would widen that guard. func ParseRepoRef(ref string) (string, bool) { + // Whitespace is refused outright rather than stored: the registry projection writes the + // slug as a whitespace-separated upstream= field, which parseLine reads back + // truncated and then rejects, breaking every project command against a rebuilt db. if strings.ContainsAny(ref, " \t\r\n") { return "", false } @@ -249,9 +246,9 @@ const ( const gateNotInitializedMarker = "repo not initialized" -// notGitRepoMarker is what `no-mistakes status` prints, exiting 0, when clonePath exists but isn't -// a git repository at all - a different, unrepairable outcome from GateNotInitialized: `no-mistakes -// init` fixes a repo that was never initialized, not a directory that isn't a git repo. +// What `no-mistakes status` prints, exiting 0, when clonePath exists but isn't a git repository +// at all - a different, unrepairable outcome from GateNotInitialized: `no-mistakes init` fixes +// a repo that was never initialized, not a directory that isn't a git repo. const notGitRepoMarker = "not in a git repository" // GateInitCommand is the exact remedy for GateNotInitialized. no-mistakes init is idempotent and @@ -261,14 +258,12 @@ func GateInitCommand(clonePath string) string { } // GateStatus asks the no-mistakes binary whether clonePath's gate is initialized, rather than -// reading ~/.no-mistakes/state.sqlite directly, which is another tool's private schema. -// no-mistakes status exits 0 whether or not the repo is initialized, so the outcome is read from -// its output text, not its exit code. Any failure to run the binary at all (missing, unexecutable, -// unexpected nonzero exit) is returned as an error distinct from GateNotInitialized: the remedy for -// a missing binary is not `no-mistakes init`. clonePath not existing on disk, and clonePath existing -// but not being a git repository, are both returned as plain errors naming the real cause too - not -// GateReady, which would let a caller dispatch into a project the gate cannot cover. +// reading ~/.no-mistakes/state.sqlite, which is another tool's private schema. The outcome comes +// from the output text: no-mistakes status exits 0 whether or not the repo is initialized. func GateStatus(clonePath string) (GateState, error) { + // A clone path missing from disk and one that is not a git repository are both plain errors + // naming the real cause, never GateReady, which would let a caller dispatch into a project + // the gate cannot cover. if _, err := os.Stat(clonePath); err != nil { return GateReady, fmt.Errorf("no-mistakes clone path: %w", err) } @@ -276,6 +271,8 @@ func GateStatus(clonePath string) (GateState, error) { cmd.Dir = clonePath out, err := cmd.CombinedOutput() if err != nil { + // Distinct from GateNotInitialized, because the remedy for a binary that is missing, + // unexecutable, or failing unexpectedly is not `no-mistakes init`. return GateReady, fmt.Errorf("no-mistakes binary not found or not runnable: %w", err) } text := string(out) diff --git a/internal/project/project_test.go b/internal/project/project_test.go index 3554892..7af606b 100644 --- a/internal/project/project_test.go +++ b/internal/project/project_test.go @@ -103,10 +103,9 @@ func TestSetUpstreamNotFound(t *testing.T) { } } -// A hand-written registry line is imported before the database exists, so an -// upstream typed as a URL has to normalize on the way in, and one that cannot -// be resolved to a slug has to refuse the whole line rather than import a -// project whose upstream would silently never match. +// A hand-written registry line is imported before the database exists, so an upstream typed as +// a URL has to normalize on the way in, and one that resolves to no slug has to refuse the whole +// line rather than import a project whose upstream would silently never match. func TestListNormalizesUpstreamFromTheRegistry(t *testing.T) { dir := t.TempDir() writeRegistry(t, dir, "- fork: https://github.com/atqamz/fork mode=direct-pr upstream=https://github.com/upstream/fork.git\n") @@ -321,19 +320,16 @@ func TestRemoveNotFound(t *testing.T) { } } -// fakeNoMistakes puts a fake no-mistakes binary at the front of PATH. It ignores its arguments and -// always exits 0, matching the real binary's observed behavior for `status`: it exits 0 whether the -// repo is initialized or not, so GateStatus reads the outcome from stdout text rather than the exit -// code. Fakes for `runs` refusals need fakeNoMistakesExit instead. +// Puts a fake no-mistakes binary at the front of PATH, ignoring its arguments and always exiting +// 0, matching the real binary for `status`: initialized or not, it exits 0, so GateStatus reads +// the outcome from stdout text. `runs` refusals need fakeNoMistakesExit instead. func fakeNoMistakes(t *testing.T, stdout string) { fakeNoMistakesExit(t, stdout, 0) } -// fakeNoMistakesExit is fakeNoMistakes with an explicit exit code, for the invocations the real -// binary refuses non-zero: `no-mistakes runs` exits 1 on both "repo not initialized" and "not in a -// git repository", where `no-mistakes status` exits 0 printing the same text. A caller must read -// the refusal from the text either way, so the fake reproduces the exit code rather than flattening -// every refusal to 0 and letting a text-check-after-exit-check regression pass here. +// fakeNoMistakes with an explicit exit code, for the invocations the real binary refuses non-zero: +// `no-mistakes runs` exits 1 on both "repo not initialized" and "not in a git repository", where +// `status` exits 0 on the same text. Flattening that to 0 would pass a real regression. func fakeNoMistakesExit(t *testing.T, stdout string, code int) { t.Helper() bin := t.TempDir() @@ -356,11 +352,9 @@ func TestGateStatusReady(t *testing.T) { } } -// TestGateStatusNotInitialized covers both real histories from atqamz/secondhand#60 at once: a -// project registered but never given a no-mistakes init, and a project whose working_path went -// stale when the fleet home was renamed (/home/atqa/fleet to /home/atqa/secondhand). Both were -// checked against the real binary and print this one text byte-for-byte, exiting 0 either way, so -// a second test replaying the same literal would assert nothing new. +// Covers both real histories from atqamz/secondhand#60 at once: a project never given a +// no-mistakes init, and one whose working_path went stale when the fleet home was renamed. Both +// print this text byte-for-byte, so a second test on the same literal asserts nothing. func TestGateStatusNotInitialized(t *testing.T) { fakeNoMistakes(t, "repo not initialized (run 'no-mistakes init' first)") @@ -388,10 +382,9 @@ func TestGateStatusMissingBinaryIsDistinctFromNotInitialized(t *testing.T) { } } -// TestGateStatusNotGitRepo covers atqamz/secondhand#97's first clone-path outcome: clonePath exists -// but isn't a git repository at all. no-mistakes status still exits 0 and prints this text verbatim, -// so without this branch GateStatus would fall through to GateReady and let a caller dispatch into a -// project the gate cannot cover. +// Covers atqamz/secondhand#97's first clone-path outcome: clonePath exists but isn't a git +// repository. no-mistakes status still exits 0 printing this text verbatim, so without the branch +// GateStatus falls through to GateReady and lets a caller dispatch into an uncovered project. func TestGateStatusNotGitRepo(t *testing.T) { fakeNoMistakes(t, "not in a git repository") @@ -408,11 +401,9 @@ func TestGateStatusNotGitRepo(t *testing.T) { } } -// TestGateStatusMissingClonePath covers atqamz/secondhand#97's second clone-path outcome: clonePath -// does not exist on disk at all, so exec.Command's chdir fails before the binary ever runs. Without -// the os.Stat check, this used to read as "no-mistakes binary not found or not runnable", which is -// true in the letter and misleading in substance - the binary is fine, the clone directory is missing. -// No fake binary is installed for this test: os.Stat must fail before GateStatus ever tries to exec. +// Covers atqamz/secondhand#97's second clone-path outcome: clonePath does not exist, so +// exec.Command's chdir fails before the binary runs. Without the os.Stat check this read as "binary +// not found or not runnable" - true in the letter, misleading in substance. func TestGateStatusMissingClonePath(t *testing.T) { missing := filepath.Join(t.TempDir(), "does-not-exist") diff --git a/internal/selfupdate/notice.go b/internal/selfupdate/notice.go index af386d7..9ba32f4 100644 --- a/internal/selfupdate/notice.go +++ b/internal/selfupdate/notice.go @@ -20,16 +20,13 @@ type versionCache struct { Latest string `json:"latest"` } -// CheckNotice returns a one-line stderr notice when a newer hand release is -// available, or "" when up to date or when the check can't be completed. It -// is bounded by checkTimeout and never fails the caller: startup version -// checks must be non-blocking and non-fatal per SPECS.md. -// -// A currentVersion that isn't semver (a build without ldflags, defaulting to -// "dev") never gets a notice: there is no released version to compare against, -// so nagging a from-source build would be noise. `hand update` still resolves -// and installs the latest release for such builds. +// CheckNotice returns a one-line stderr notice when a newer hand release is available, or +// "" when up to date or when the check can't be completed. Bounded by checkTimeout and +// never fails the caller: startup version checks are non-blocking and non-fatal per SPECS. func CheckNotice(home, repo, currentVersion string) string { + // A version that isn't semver (a build without ldflags, defaulting to "dev") has no + // released version to compare against, so nagging a from-source build would be noise. + // `hand update` still resolves and installs the latest release for such builds. if _, _, _, err := parseSemver(currentVersion); err != nil { return "" } diff --git a/internal/selfupdate/selfupdate.go b/internal/selfupdate/selfupdate.go index 5173f43..5ac467c 100644 --- a/internal/selfupdate/selfupdate.go +++ b/internal/selfupdate/selfupdate.go @@ -40,10 +40,9 @@ func latestTag(ctx context.Context, repo string) (string, error) { return out, nil } -// ReleaseNotes returns the release body for tag, describing what changed. -// Callers should treat a returned error as "no notes available" rather than -// fail the update over it: the version replacement already succeeded, and -// missing or empty notes shouldn't undo that. +// ReleaseNotes returns the release body for tag. Callers should treat an error as "no +// notes available" rather than fail the update over it: the version replacement already +// succeeded, and missing or empty notes shouldn't undo that. func ReleaseNotes(repo, tag string) (string, error) { return releaseNotes(context.Background(), repo, tag) } @@ -98,11 +97,9 @@ func parseSemver(s string) (major, minor, patch int, err error) { // the real test binary produced by `go test`. var ExecutableOverride = os.Executable -// Apply downloads the release tagged tag from repo, verifies its checksum, and -// replaces the running binary in place. The replacement is atomic: the new -// binary is written to a temp file in the same directory as the running -// binary, then renamed over it, so a crash mid-update never leaves a partial -// binary at the real path. +// Apply downloads the release tagged tag from repo, verifies its checksum, and replaces +// the running binary in place. The replacement is atomic - temp file in the same directory, +// renamed over it - so a crash mid-update never leaves a partial binary at the real path. func Apply(repo, tag string) error { execPath, err := ExecutableOverride() if err != nil { diff --git a/internal/selfupdate/selfupdate_test.go b/internal/selfupdate/selfupdate_test.go index eb4a66d..15cfb53 100644 --- a/internal/selfupdate/selfupdate_test.go +++ b/internal/selfupdate/selfupdate_test.go @@ -43,13 +43,9 @@ func TestIsNewerRejectsInvalidLatest(t *testing.T) { } } -// writeFakeGH fakes the two gh calls an update makes. Real `gh release view -// --json ... --jq` prints the extracted field alone on stdout with exit 0, and -// `gh release download --dir` writes the assets into that directory while its -// progress goes to stderr; runGH (selfupdate.go) reads stdout only, so this -// fake mirrors both by keeping stdout to the payload. On failure real gh exits -// nonzero with the reason on stderr, which the unexpected-invocation arm below -// mirrors. +// Fakes the two gh calls an update makes, mirroring where real gh puts its output: the +// extracted field alone on stdout, download progress on stderr, and a nonzero exit with +// the reason on stderr for a failure. runGH reads stdout only, so that split matters. func writeFakeGH(t *testing.T, tag, fixtureDir string) { t.Helper() bin := t.TempDir() diff --git a/internal/state/hold.go b/internal/state/hold.go index 9836488..2038e07 100644 --- a/internal/state/hold.go +++ b/internal/state/hold.go @@ -39,11 +39,9 @@ func ClearHold(homeDir, id string) error { return db.ClearHold(id) } -// SetHoldIfNotOtherKind writes h unless the id already carries a hold of a different -// kind, and reports whether it wrote. It is the set-side counterpart of -// ClearHoldIfKind: a machine-set hold that overwrote an operator's own hold would not -// merely hide their open question, since the later ClearHoldIfKind matching its own -// kind then deletes the row outright. +// SetHoldIfNotOtherKind writes h unless the id already carries a hold of a different kind, and reports +// whether it wrote. Set-side counterpart of ClearHoldIfKind: a machine-set hold overwriting an +// operator's own would not merely hide their question - the later kind-matched clear deletes the row. func SetHoldIfNotOtherKind(homeDir string, h Hold) (bool, error) { existing, exists, err := ReadHold(homeDir, h.ID) if err != nil { diff --git a/internal/state/pr.go b/internal/state/pr.go index 40c80a0..0eb6f81 100644 --- a/internal/state/pr.go +++ b/internal/state/pr.go @@ -5,11 +5,9 @@ import ( "strings" ) -// prURLPattern is the sole boundary between an untrusted string and a PR URL -// hand will ever store: strictly anchored end-to-end, no substring matching, -// since a stored value feeds gh pr merge and ghutil.PRIsMerged directly - a -// loose match here is a command-injection-adjacent risk, not just a validation -// nicety. +// The sole boundary between an untrusted string and a PR URL hand will ever store, +// anchored end-to-end with no substring matching: a stored value feeds gh pr merge and +// ghutil.PRIsMerged directly, so a loose match is a command-injection-adjacent risk. var prURLPattern = regexp.MustCompile(`^https://github\.com/([A-Za-z0-9._-]+)/([A-Za-z0-9._-]+)/pull/([0-9]+)$`) func ValidatePRURL(url string) bool { diff --git a/internal/state/report.go b/internal/state/report.go index 8059ad7..7cde366 100644 --- a/internal/state/report.go +++ b/internal/state/report.go @@ -76,32 +76,19 @@ func ParseReportLine(line string) ReportLine { return ReportLine{State: prefix, Note: note, Raw: line} } -// ReportCursor is how far a task's report channel has been consumed: the byte -// offset, and a fingerprint of the bytes that offset covers. A worker reporting -// with a truncating redirect rewrites the file in place rather than appending to -// it, and the offset alone cannot tell such a rewrite from an unchanged file. A -// rewrite whose total length happens to equal the offset leaves every quantity -// the offset has to offer identical - it still sits just past the file's final -// newline, with nothing after it - so the report is silently skipped, and a -// same-length `done:` rewrite means a finished worker never classifies as -// finished (atqamz/secondhand#149). The fingerprint is what differs, so it is -// what decides whether the offset still means anything. +// How far a task's report channel has been consumed. A worker reporting with a truncating redirect +// rewrites the file in place rather than appending, and a rewrite whose length happens to equal the +// offset leaves every quantity the offset alone can offer identical (atqamz/secondhand#149). type ReportCursor struct { Offset int64 + // What tells that same-length rewrite apart: it still sits just past a final newline with nothing + // after it, so only a fingerprint of the covered bytes decides whether Offset still means + // anything - without one a same-length `done:` rewrite never classifies as finished. Digest string } -// covers reports whether c still describes this file's own history, so that the -// bytes past c.Offset are newly written rather than a slice of a file that has -// been replaced wholesale. -// -// An empty digest is a cursor persisted before hand recorded one, so there is -// nothing to compare against and the check falls back to the newline boundary -// every offset hand persists sits on: a consumed line can end nowhere else, so -// an offset whose preceding byte is not a newline points into the middle of a -// line a longer rewrite replaced (atqamz/secondhand#140). That is exactly the -// guard such a cursor was written under, and it stands until the next tick -// records a digest for it. +// Reports whether c still describes this file's own history, so the bytes past c.Offset are newly +// written rather than a slice of a file that has been replaced wholesale. func (c ReportCursor) covers(data []byte) bool { if c.Offset < 0 || c.Offset > int64(len(data)) { return false @@ -109,12 +96,14 @@ func (c ReportCursor) covers(data []byte) bool { if c.Digest != "" { return c.Digest == reportDigest(data[:c.Offset]) } + // An empty digest is a cursor persisted before hand recorded one, so the check falls back to the + // newline boundary every persisted offset sits on: an offset whose preceding byte is not a newline + // points into the middle of a line a longer rewrite replaced (atqamz/secondhand#140). return c.Offset == 0 || data[c.Offset-1] == '\n' } -// reportCursorFor is the cursor that says consumed has been consumed. An empty -// prefix gets the zero cursor rather than the digest of nothing: a cursor at -// offset 0 covers any file already, and a digest there would be a value the +// The cursor that says consumed has been consumed. An empty prefix gets the zero cursor rather than +// the digest of nothing: offset 0 covers any file already, and a digest there would be a value the // watcher persists for every task whose worker has yet to report a line. func reportCursorFor(consumed []byte) ReportCursor { if len(consumed) == 0 { @@ -128,13 +117,9 @@ func reportDigest(consumed []byte) string { return hex.EncodeToString(sum[:]) } -// readReport reads a task's report file whole, and returns it alongside the -// cursor its content actually supports: the caller's own where that still -// describes this file, and a zero cursor where it does not, so a rewritten file -// is read from the beginning. A file shorter than the offset restarts the same -// way. Re-announcing a report costs less than inventing a broken one out of a -// file that has been replaced, or skipping one because the replacement happens -// to be the same size. +// Reads a task's report file whole, alongside the cursor its content actually supports: the caller's +// own where that still describes this file, and a zero cursor where it does not - a file shorter than +// the offset included. func readReport(path string, cur ReportCursor) ([]byte, ReportCursor, error) { data, err := os.ReadFile(path) if os.IsNotExist(err) { @@ -143,16 +128,17 @@ func readReport(path string, cur ReportCursor) ([]byte, ReportCursor, error) { if err != nil { return nil, cur, fmt.Errorf("read report %s: %w", path, err) } + // Re-announcing a report costs less than inventing a broken one out of a file that has been + // replaced, or skipping one because the replacement happens to be the same size. if !cur.covers(data) { return data, ReportCursor{}, nil } return data, cur, nil } -// TailReport reads whatever complete lines have arrived in a task's report file -// since cur, returning them alongside the new cursor to persist. A trailing line -// with no terminating newline is left unconsumed in case the worker's append is -// still in flight. +// TailReport reads whatever complete lines have arrived in a task's report file since cur, alongside +// the new cursor to persist. A trailing line with no terminating newline is left unconsumed in case +// the worker's append is still in flight. func TailReport(path string, cur ReportCursor) ([]ReportLine, ReportCursor, error) { data, base, err := readReport(path, cur) if err != nil { @@ -190,11 +176,9 @@ func ReadReportLines(homeDir, id string) ([]ReportLine, error) { return classifyReportBytes(data), nil } -// classifyReportBytes classifies every line in data, a trailing line with no -// terminating newline included. That last line is the difference between a -// snapshot reader and TailReport: a watcher leaves it for its next tick because -// it will still be there, while a reader answering a question about right now -// has to account for it. +// Classifies every line in data, a trailing line with no terminating newline included. That last +// line is the difference between a snapshot reader and TailReport: a watcher leaves it for its next +// tick because it will still be there, while a reader answering a question about now cannot. func classifyReportBytes(data []byte) []ReportLine { var lines []ReportLine for _, l := range strings.Split(string(data), "\n") { @@ -206,27 +190,20 @@ func classifyReportBytes(data []byte) []ReportLine { return lines } -// blankReportLine is the skip rule both readers share, so hand status never -// shows an entry hand watch didn't surface. Whitespace-only counts as blank: -// otherwise a trailing " \n" would become the last (malformed) report and -// mask a real terminal report sitting right above it. +// The skip rule both readers share, so hand status never shows an entry hand watch didn't surface. +// Whitespace-only counts as blank: otherwise a trailing " \n" would become the last (malformed) +// report and mask a real terminal report sitting right above it. func blankReportLine(line string) bool { return strings.TrimSpace(line) == "" } -// LastReportedState returns the most recent line in lines whose state classified - -// the last thing the worker actually said about itself - skipping trailing malformed -// lines rather than letting one erase it. Free text explains nothing, so a worker -// that appends some after a real report has still reported: this is the rule the -// live classifier follows (see ClassifyReportLine), and a reader that recovers the -// last known report state from the file has to reach the same answer. -// -// It takes lines rather than reading the file so a caller that also needs the raw -// last line gets both from one read. Two reads are two snapshots, and a worker -// appending between them would have one row's raw line and its own classified state -// describing different reports - the same two-views-disagree defect the report -// channel exists to remove. +// LastReportedState returns the most recent line whose state classified - the last thing the worker +// actually said about itself - skipping trailing malformed lines rather than letting one erase it. +// Free text explains nothing, so appending some after a real report still counts (ClassifyReportLine). func LastReportedState(lines []ReportLine) (ReportLine, bool) { + // Taking lines rather than reading the file lets a caller that also needs the raw last line get + // both from one read: two reads are two snapshots, and a worker appending between them would have + // one row's raw line and its classified state describing different reports. for i := len(lines) - 1; i >= 0; i-- { if !lines[i].Malformed { return lines[i], true @@ -241,36 +218,25 @@ func TerminalReport(s string) bool { return s == ReportDone || s == ReportFailed } -// UnacknowledgedTerminalReport reports a terminal state no hand watch has ever -// consumed, from the task's durable report cursor. That cursor is the marker: the -// poll loop advances it only after the tick's events are announced, and every -// announcement reaches state/events.log and the notify hook, so a terminal line -// still past it has reached nobody (atqamz/secondhand#70). A cursor the file's own -// content no longer supports covers nothing, so a rewritten channel is read whole - -// including the rewrite that kept the file's length, whose `done:` line no watcher -// has announced either (atqamz/secondhand#149). -// -// Only the last classified line of that unconsumed tail counts. A terminal -// report a worker has since superseded with more work needs no acknowledging, -// and a done report is routinely followed by more work on the same worker. -// -// It classifies its own tail rather than calling TailReport, so that a terminal -// line with no terminating newline counts too: TailReport leaves that line for -// the watcher's next tick, and a report the watcher will not announce until then -// is precisely one that has reached nobody yet. Skipping it would let the silent -// completion this exists to surface back in through the newline. -// -// A watcher denied the task lock announces a line and persists the offset a tick -// later, so the transient error here is reporting an acknowledged terminal state, -// never hiding an unacknowledged one. +// UnacknowledgedTerminalReport reports a terminal state no hand watch has ever consumed. The task's +// durable report cursor is the marker: the poll loop advances it only after a tick's events are +// announced, and every announcement reaches state/events.log and the notify hook. func UnacknowledgedTerminalReport(homeDir, id string, cur ReportCursor) (bool, error) { + // So a terminal line still past that cursor has reached nobody (atqamz/secondhand#70). A cursor the + // file's content no longer supports covers nothing, so a rewritten channel is read whole - the + // length-preserving rewrite included, whose `done:` no watcher announced (atqamz/secondhand#149). data, base, err := readReport(ReportPath(homeDir, id), cur) if err != nil { return false, err } + // Only the last classified line of the unconsumed tail counts: a terminal report the worker has + // since superseded needs no acknowledging, and done is routinely followed by more work. It + // classifies its own tail so a line with no terminating newline counts - TailReport would defer it. last, ok := LastReportedState(classifyReportBytes(data[base.Offset:])) if !ok { return false, nil } + // A watcher denied the task lock announces a line and persists the cursor a tick later, so the + // transient error here is reporting an acknowledged terminal state, never hiding an unacknowledged one. return TerminalReport(last.State), nil } diff --git a/internal/state/report_test.go b/internal/state/report_test.go index 6e4e686..820203a 100644 --- a/internal/state/report_test.go +++ b/internal/state/report_test.go @@ -6,11 +6,9 @@ import ( "testing" ) -// dogfoodReportLines is the literal content of a real report file captured -// from a production dogfood run (/home/atqa/handlab/state/release-dispatch.status): -// a worker that reported working/needs-decision/done the whole time while hand -// never read a single line of it. Used verbatim here instead of invented -// examples so the parser is proven against real data. +// Literal content of a report file captured from a dogfood run +// (/home/atqa/handlab/state/release-dispatch.status), where a worker reported the whole +// time and hand read nothing. Verbatim, not invented, so the parser meets real data. const dogfoodReportLines = `working: workflow_dispatch added to release.yaml, invoking no-mistakes needs-decision: review gate on PR for #20 raised 2 ask-user findings - (1) concurrency group release-${{ github.ref }} does not serialize manual dispatch against push-triggered runs on main, risking concurrent release-please runs; (2) dispatch replays same release-please step that already no-op'd on issue #20, may not unblock the conflicted PR without also deleting/recreating the release branch. Run parked at review gate, run id 01KYEVGV26MD8X08MZY2VXXCSR on branch 20-release-workflow-dispatch. done: PR https://github.com/atqamz/secondhand/pull/31 checks green @@ -138,11 +136,9 @@ func TestTailReportRestartsFromZeroWhenFileShrinks(t *testing.T) { } } -// Three reports captured off this fleet's live report files, each rewritten in -// place over a shorter earlier report the watcher had already consumed. Every one -// of them was announced as "malformed report" with a mid-word fragment of itself -// as the event text (atqamz/secondhand#140), because the consumed offset survived -// the rewrite and landed inside the longer new line. +// Three reports captured off this fleet's live report files, each rewritten in place over a +// shorter consumed one. All were announced as "malformed report" with a mid-word fragment of +// themselves (atqamz/secondhand#140): the consumed offset survived the rewrite, mid-line. func TestTailReportAfterInPlaceRewrite(t *testing.T) { cases := []struct { name string @@ -237,11 +233,9 @@ func TestUnacknowledgedTerminalReportAfterInPlaceRewrite(t *testing.T) { } } -// The rewrite that carries no length change at all: reports are one line of -// house-style prose, so two consecutive ones landing on the same byte count is a -// matter of time. Every quantity the offset has to offer is identical across such -// a rewrite - it still sits just past the file's final newline, with nothing after -// it - so the new report was skipped entirely (atqamz/secondhand#149). +// The rewrite that carries no length change at all: reports are one line of house-style prose, so two +// consecutive ones landing on the same byte count is a matter of time, and every quantity the offset +// has to offer is identical across it - so the new report was skipped entirely (atqamz/secondhand#149). func TestTailReportAfterSameLengthInPlaceRewrite(t *testing.T) { cases := []struct { name string @@ -333,10 +327,9 @@ func TestUnacknowledgedTerminalReportAfterSameLengthRewrite(t *testing.T) { } } -// A cursor from a row written before report_digest existed keeps the guard it was -// written under: the same-length rewrite is missed exactly as before, and the -// longer one atqamz/secondhand#140 covers is still caught. The alternative is -// replaying every consumed line on the tick after an upgrade. +// A cursor from a row written before report_digest existed keeps the guard it was written under: the +// same-length rewrite is missed exactly as before, and the longer one atqamz/secondhand#140 covers is +// still caught. The alternative is replaying every consumed line on the tick after an upgrade. func TestTailReportFallsBackToTheNewlineBoundaryWithoutADigest(t *testing.T) { path := filepath.Join(t.TempDir(), "task-1.status") consumed := "working: same-length rewrite now reproduced in the tests\n" diff --git a/internal/state/task.go b/internal/state/task.go index 35f2d49..80fb25e 100644 --- a/internal/state/task.go +++ b/internal/state/task.go @@ -130,24 +130,19 @@ func List(homeDir string) ([]Task, error) { return db.ListTasks() } -// Delete removes a task's row along with its report channel at -// state/.status. The report file is the volatile wake log, not a -// deliverable: a task respawned under a used ID starts at report_offset 0, so a -// surviving log would replay the previous run's lines as if they were new - -// re-raising resolved decisions, absorbing a genuine unexplained stop, and -// auto-recording a PR URL out of an old done line onto a task nobody recorded it -// for. The durable deliverables (data//) survive teardown as before. -// -// The report channel goes first, not last: that removal is the one that can -// fail on a permissions or I/O fault, and doing it first means the fault leaves -// nothing durable gone yet, so the whole command is simply retryable. Removing -// the row first would let a report-removal failure strand the caller with the -// state already gone and no way to retry (see cmd/teardown.go's guarded path). +// Delete removes a task's row along with its report channel at state/.status, leaving +// the durable deliverables in data//. That file is the volatile wake log: a respawn +// under a used ID starts at report_offset 0, so a surviving log replays as new lines. func Delete(homeDir, id string) error { if err := ValidateID(id); err != nil { return err } + // A replayed log re-raises resolved decisions, absorbs a genuine unexplained stop, and + // auto-records a PR URL out of an old done line onto a task nobody recorded it for. if err := os.Remove(ReportPath(homeDir, id)); err != nil && !os.IsNotExist(err) { + // Failing here leaves nothing durable gone yet, so the whole command is retryable. + // Removing the row first would strand the caller with the state gone and no way to + // retry (see cmd/teardown.go's guarded path). return fmt.Errorf("remove report channel %q: %w", id, err) } db, err := store.Open(homeDir) diff --git a/internal/state/task_test.go b/internal/state/task_test.go index b620944..7c76bed 100644 --- a/internal/state/task_test.go +++ b/internal/state/task_test.go @@ -131,10 +131,9 @@ func TestDelete(t *testing.T) { } } -// TestDeleteRemovesTheReportChannel pins the cleanup a respawn depends on: a new -// task under a used ID starts at report_offset 0, so a surviving wake log would -// be replayed as if it were this run's - re-raising resolved decisions and -// auto-recording a PR URL out of the previous run's done line. +// Pins the cleanup a respawn depends on: a new task under a used ID starts at +// report_offset 0, so a surviving wake log replays as this run's - re-raising resolved +// decisions and auto-recording a PR URL out of the previous run's done line. func TestDeleteRemovesTheReportChannel(t *testing.T) { dir := t.TempDir() if err := Write(dir, Task{ID: "fix-login"}); err != nil { diff --git a/internal/store/hold_test.go b/internal/store/hold_test.go index fc4b61a..20f39b5 100644 --- a/internal/store/hold_test.go +++ b/internal/store/hold_test.go @@ -56,11 +56,9 @@ func TestReadHoldReportsAMissingHoldWithoutAnError(t *testing.T) { } } -// TestHoldSurvivesWithNoTaskRowBehindIt pins the design decision that makes a -// hold cover the motivating case: it is its own row keyed by an arbitrary id, -// not a foreign key into task, so a hold set on a task torn down while its -// question stayed open still answers "what needs the operator" after -// DeleteTask removes the task row that used to carry it. +// Pins the decision that makes a hold cover the motivating case: its own row keyed by an +// arbitrary id, not a foreign key into task, so a hold set on a task torn down with its +// question open still answers "what needs the operator" after DeleteTask. func TestHoldSurvivesWithNoTaskRowBehindIt(t *testing.T) { db, _ := openTemp(t) if err := db.WriteTask(Task{ID: "fix-login"}); err != nil { @@ -136,11 +134,9 @@ func TestListHoldsSortedAndEmpty(t *testing.T) { } } -// TestListHoldsSurfacesEveryRowRegardlessOfKind pins the read side of the -// design decision in SPECS.md: a row an external write left inconsistent -// (here, an unrecognized kind) must still come back from ListHolds, not be -// filtered out, or "what is held" silently drops exactly the row most worth -// seeing. +// Pins the read side of the design decision in SPECS.md: a row an external write left +// inconsistent (here, an unrecognized kind) must still come back from ListHolds, or "what +// is held" silently drops exactly the row most worth seeing. func TestListHoldsSurfacesEveryRowRegardlessOfKind(t *testing.T) { db, _ := openTemp(t) if err := db.SetHold(Hold{ID: "weird", Kind: "not-a-real-kind", Reason: "who knows"}); err != nil { diff --git a/internal/store/index.go b/internal/store/index.go index e8ac590..885965a 100644 --- a/internal/store/index.go +++ b/internal/store/index.go @@ -236,10 +236,9 @@ func (ix *Index) Search(query string, limit int) ([]Hit, error) { return hits, nil } -// Nothing renders data/dashboard.md any more (atqamz/secondhand#62), but a -// home initialized before its deletion keeps the last render on disk forever, -// and indexing that would answer a prose search out of a frozen snapshot of -// removed functionality. +// Nothing renders data/dashboard.md any more (atqamz/secondhand#62), but a home initialized +// before its deletion keeps the last render on disk forever, and indexing that would answer a +// prose search out of a frozen snapshot of removed functionality. var generatedCorpusFiles = map[string]bool{ filepath.Join("data", "dashboard.md"): true, } diff --git a/internal/store/migrate.go b/internal/store/migrate.go index 472aa96..1cfb94b 100644 --- a/internal/store/migrate.go +++ b/internal/store/migrate.go @@ -93,11 +93,9 @@ func readLegacyTask(path, id string) (Task, error) { if t.ID != id { return Task{}, fmt.Errorf("legacy task state %s has mismatched ID %q (move it aside to continue)", path, t.ID) } - // A file predating the pane-start column carries no such key, and the import - // lands as an INSERT the schema migration's backfill never sees. Same CASE as - // that backfill, so a task promoted before either existed still gets its own - // pane's start rather than its scout's creation instant, which would be the - // false `parked` of atqamz/secondhand#128. + // A file predating the pane-start column carries no such key, so the import lands as an + // INSERT the backfill never sees. Same CASE as that backfill, so a task promoted before + // either existed gets its own pane's start, not atqamz/secondhand#128's false one. if t.PaneStartedAt == "" { t.PaneStartedAt = t.StatusChangedAt if t.PaneStartedAt == "" { diff --git a/internal/store/schemaversion.go b/internal/store/schemaversion.go index 9f57cca..41b71b7 100644 --- a/internal/store/schemaversion.go +++ b/internal/store/schemaversion.go @@ -10,12 +10,9 @@ import ( // apart from every other reason Open can fail. var ErrSchemaNewer = errors.New("schema version newer than this build of hand supports") -// migrations lists every schema change since the version-0 baseline the -// `schema` constant in store.go builds, applied in commit order to a database -// that already exists. A database that predates this mechanism reads PRAGMA -// user_version as 0 by sqlite's own default, and that has to mean "the schema -// this commit ships", not "unknown, refuse to proceed" - otherwise the one -// fleet home that exists stops opening the moment this merges. +// Every schema change since the version-0 baseline `schema` builds, applied in commit order +// to a database that already exists. Version 0 is sqlite's own default, so it has to mean +// "the schema this commit ships" or the one fleet home that exists stops opening. var migrations = []string{ `ALTER TABLE task ADD COLUMN send_undelivered_message TEXT NOT NULL DEFAULT ''; ALTER TABLE task ADD COLUMN send_undelivered_at TEXT NOT NULL DEFAULT '';`, @@ -23,18 +20,15 @@ var migrations = []string{ `ALTER TABLE project ADD COLUMN upstream TEXT NOT NULL DEFAULT '';`, `ALTER TABLE task ADD COLUMN delivered_at TEXT NOT NULL DEFAULT ''; ALTER TABLE task ADD COLUMN delivered_reason TEXT NOT NULL DEFAULT '';`, - // The backfill freezes each existing row's pane start at the value the - // pre-migration floor was already computing for it. Backfilling from - // created_at instead would hand a task promoted before this migration its - // scout's creation instant, which is the false `parked` this floor exists to - // prevent; the stamp only ever overstates the floor, which delays a true one. + // The backfill freezes each row's pane start at what the pre-migration floor already + // computed for it. created_at would hand a task promoted earlier its scout's creation + // instant - the false `parked` this floor prevents; overstating only delays a true one. `ALTER TABLE task ADD COLUMN pane_started_at TEXT NOT NULL DEFAULT ''; ALTER TABLE task ADD COLUMN parked_fired_for TEXT NOT NULL DEFAULT ''; UPDATE task SET pane_started_at = CASE WHEN status_changed_at <> '' THEN status_changed_at ELSE created_at END;`, - // No backfill: the digest of an existing row's consumed prefix cannot be - // recovered from a report file that may already have been rewritten, and an - // empty one is what the reader falls back to the newline boundary for. The - // first tick that consumes a line records it. + // No backfill: the digest of an existing row's consumed prefix cannot be recovered from a report + // file that may already have been rewritten, and an empty one is what the reader falls back to the + // newline boundary for. The first tick that consumes a line records it. `ALTER TABLE task ADD COLUMN report_digest TEXT NOT NULL DEFAULT '';`, // No backfill: an empty retry stamp is exactly "this task is not limited", which // is the honest reading of every row written before hand could detect a limit. @@ -58,21 +52,9 @@ func schemaVersionError(current, latest int) error { current, latest, ErrSchemaNewer) } -// migrateSchema is the first statement Open runs against the database: a -// version newer than this binary knows is refused before the baseline -// `schema` even executes, so an old hand never guesses at a layout it does -// not understand. Bringing an old database up to date takes a lock because -// sqlite's per-statement locking cannot make "add this column, then bump -// user_version" atomic across a whole open - without it, two hand processes -// racing to migrate the same freshly-upgraded home would have the loser see -// "duplicate column name" instead of a clean, idempotent no-op. -// -// A database with no tables yet is built by `schema` and stamped straight to -// the latest version, migrations skipped: `schema` is the current layout, -// every column a registered migration adds included, so replaying that list -// on top of it would fail with "duplicate column name" on every fresh home -// while the already-migrated ones kept working - the test-passes, -// production-fails asymmetry this whole mechanism exists to remove. +// The first statement Open runs against the database: a version newer than this binary +// knows is refused before the baseline `schema` even executes, so an old hand never +// guesses at a layout it does not understand. func (db *DB) migrateSchema() error { current, err := db.schemaVersion() if err != nil { @@ -83,6 +65,9 @@ func (db *DB) migrateSchema() error { return err } if current < latest { + // sqlite's per-statement locking cannot make "add this column, then bump + // user_version" atomic across a whole open. Without the lock, two processes racing + // to migrate the same home leave the loser a "duplicate column name" error. unlock, err := Lock(db.home, SchemaLock, false) if err != nil { return fmt.Errorf("lock schema migration: %w", err) @@ -108,6 +93,9 @@ func (db *DB) migrateSchema() error { if err := db.createSchema(isNew, latest); err != nil { return err } + // `schema` is the current layout, every column a registered migration adds included, so + // replaying the list over a fresh home fails with "duplicate column name" while the + // already-migrated homes keep working - a test-passes, production-fails asymmetry. if isNew { return nil } @@ -119,11 +107,9 @@ func (db *DB) migrateSchema() error { return nil } -// One transaction, because a new database's tables and the version stamp that -// says migrations are already folded into them have to land together: a crash -// between the two would leave the migrated columns present at version 0, and -// every later open replaying those migrations against them - a freshly -// initialized home no operator step short of a hand-written PRAGMA reopens. +// One transaction, because a new database's tables and the version stamp saying migrations +// are already folded into them have to land together: a crash between the two strands the +// home at version 0 with migrated columns, replaying them forever short of a raw PRAGMA. func (db *DB) createSchema(isNew bool, latest int) error { tx, err := db.sql.Begin() if err != nil { diff --git a/internal/store/schemaversion_test.go b/internal/store/schemaversion_test.go index fe3b63f..eb118a1 100644 --- a/internal/store/schemaversion_test.go +++ b/internal/store/schemaversion_test.go @@ -6,11 +6,9 @@ import ( "testing" ) -// migrationsContaining narrows the registered list to the entries naming -// substr. A test that exercises one real entry has to replay only that entry: -// replaying the whole list would hit "duplicate column name" on every column -// `schema` builds that the test did not drop, and naming the entry by index -// instead would silently shift the moment another commit appends one. +// Narrows the registered list to the entries naming substr, so a test replays only the one +// entry it exercises: the whole list would hit "duplicate column name" on every column +// `schema` builds, and an index would silently shift when another commit appends one. func migrationsContaining(substr string) []string { var matched []string for _, m := range migrations { @@ -21,10 +19,9 @@ func migrationsContaining(substr string) []string { return matched } -// A fresh database is built by `schema`, which already carries every -// registered migration, so it is stamped straight to the latest version -// rather than 0 - only a database that predates the mechanism entirely reads -// as 0 (TestExistingBaselineDatabaseOpensCleanly below). +// A fresh database is built by `schema`, which already carries every registered migration, +// so it is stamped straight to the latest version rather than 0 - only a database predating +// the mechanism reads as 0 (TestExistingBaselineDatabaseOpensCleanly below). func TestFreshOpenRecordsSchemaVersionAtLatest(t *testing.T) { db, _ := openTemp(t) version, err := db.schemaVersion() @@ -86,22 +83,18 @@ func TestOpenRefusesADatabaseNewerThanThisBuild(t *testing.T) { } } -// The real hand.db this mechanism has to keep working is exactly the shape -// this sets up: tables already created by the pre-mechanism schema, -// user_version at its sqlite default of 0, no meta row claiming a version. -// Registering a migration step is meant to be the whole job for a column -// addition to such a database: one entry, applied automatically without an -// operator running anything, and cheap to run again on the next open. +// The real hand.db this mechanism has to keep working is exactly the shape this sets up: +// tables from the pre-mechanism schema, user_version at sqlite's default 0, no meta row. +// One registered entry is meant to be the whole job: automatic, and cheap to run again. func TestPendingMigrationAppliesAutomaticallyAndOnlyOnce(t *testing.T) { home := t.TempDir() restore := migrations t.Cleanup(func() { migrations = restore }) - // Empty migrations for this first open, so the fresh database it stamps - // reads as version 0 - the pre-mechanism baseline this test needs - - // rather than picking up whatever this build's real migrations already - // registered. + // Empty migrations for this first open, so the fresh database it stamps reads as version + // 0 - the pre-mechanism baseline this test needs - rather than picking up whatever this + // build's real migrations already registered. migrations = []string{} existing, err := Open(home) if err != nil { @@ -145,12 +138,9 @@ func TestPendingMigrationAppliesAutomaticallyAndOnlyOnce(t *testing.T) { defer func() { _ = second.Close() }() } -// Adding a column puts it in the `schema` constant, so new databases are built -// with it, and appends the matching ALTER TABLE to `migrations`, so existing -// ones gain it. A brand-new database must take only the first of those: the -// column is already there, and replaying the migration would fail with -// "duplicate column name" on every fresh home. `mode` stands in for such a -// column - the baseline `project` table already has it. +// Adding a column puts it in `schema`, so new databases are built with it, and appends the +// matching ALTER TABLE to `migrations`, so existing ones gain it. A fresh database must take +// only the first, or replay fails with "duplicate column name"; `mode` stands in. func TestFreshDatabaseSkipsAMigrationTheSchemaAlreadyBuilds(t *testing.T) { restore := migrations migrations = []string{`ALTER TABLE project ADD COLUMN mode TEXT NOT NULL DEFAULT ''`} @@ -190,10 +180,9 @@ func TestSendUndeliveredColumnsMigrateOntoAnExistingDatabase(t *testing.T) { own := migrationsContaining("send_undelivered_message") t.Cleanup(func() { migrations = restore }) - // Empty migrations for this first open, so the fresh database it builds - // reads as version 0; `schema` still creates the two new columns (it can't - // be swapped the way `migrations` can), so they are dropped by hand to put - // the database back into the pre-migration shape this test needs. + // Empty migrations for this first open, so the fresh database reads as version 0. + // `schema` still creates the two new columns (it cannot be swapped the way `migrations` + // can), so they are dropped by hand to reach the pre-migration shape. migrations = []string{} existing, err := Open(home) if err != nil { @@ -232,10 +221,9 @@ func TestSendUndeliveredColumnsMigrateOntoAnExistingDatabase(t *testing.T) { } } -// The live fleet home holds rows written before lease_id existed, and they have -// to keep being readable through the migration rather than only after their task -// is respawned - so the column arrives empty on every one of them, which is -// exactly what worktree.CheckCollision's path fallback keys on. +// The live fleet home holds rows written before lease_id existed, and they stay readable +// through the migration rather than only after a respawn - so the column arrives empty on +// every one of them, which is what worktree.CheckCollision's path fallback keys on. func TestLeaseIDColumnMigratesOntoAnExistingDatabase(t *testing.T) { home := t.TempDir() @@ -281,10 +269,9 @@ func TestLeaseIDColumnMigratesOntoAnExistingDatabase(t *testing.T) { } } -// Exercises the real delivered_at/delivered_reason entry against a database -// holding a task row written before those columns existed - the live fleet -// home's shape - so a task spawned before this commit stays readable and reads -// as not delivered rather than making the whole database unopenable. +// Exercises the real delivered_at/delivered_reason entry against a database holding a task +// row written before those columns existed - the live fleet home's shape - so a task spawned +// earlier stays readable and reads as not delivered rather than blocking the whole open. func TestDeliveredColumnsMigrateOntoAnExistingDatabase(t *testing.T) { home := t.TempDir() @@ -340,12 +327,9 @@ func TestDeliveredColumnsMigrateOntoAnExistingDatabase(t *testing.T) { } } -// Exercises the real pane_started_at/parked_fired_for entry against a database -// holding rows written before those columns existed. pane_started_at is -// backfilled rather than left empty: the pre-migration `parked` floor read -// status_changed_at and fell back to created_at, so freezing that same value is -// what stops the migration from either sliding a live task's floor or handing a -// task promoted before the migration its scout's whole accumulated silence. +// Exercises the real pane_started_at/parked_fired_for entry against a database holding rows +// written before those columns existed. The pre-migration `parked` floor read +// status_changed_at and fell back to created_at, so the backfill freezes that same value. func TestPaneStartColumnsMigrateOntoAnExistingDatabase(t *testing.T) { home := t.TempDir() @@ -403,10 +387,9 @@ func TestPaneStartColumnsMigrateOntoAnExistingDatabase(t *testing.T) { } } -// Exercises the real project.upstream entry against a database holding a -// project row written before that column existed - the live fleet home's -// shape - so a project registered long ago stays readable and gains the -// column empty rather than the whole registry failing to open. +// Exercises the real project.upstream entry against a database holding a project row written +// before that column existed - the live fleet home's shape - so a project registered long ago +// stays readable and gains the column empty rather than the whole registry failing to open. func TestProjectUpstreamColumnMigratesOntoAnExistingDatabase(t *testing.T) { home := t.TempDir() @@ -458,13 +441,9 @@ func TestProjectUpstreamColumnMigratesOntoAnExistingDatabase(t *testing.T) { } } -// Exercises the real report_digest entry against a database holding a task row -// whose worker has already reported - a live fleet home's shape. The column -// arrives empty rather than backfilled on purpose: the digest of the prefix -// that offset already consumed cannot be recovered from a file that may have -// been rewritten since, and empty is what state.ReportCursor falls back to the -// newline boundary for. The offset itself has to survive, or the upgrade -// replays every line the previous run already surfaced. +// Exercises the real report_digest entry against a database holding a task row whose worker has +// already reported - a live fleet home's shape. The column arrives empty rather than backfilled on +// purpose: the consumed prefix cannot be recovered from a file that may have been rewritten since. func TestReportDigestColumnMigratesOntoAnExistingDatabase(t *testing.T) { home := t.TempDir() @@ -503,6 +482,8 @@ func TestReportDigestColumnMigratesOntoAnExistingDatabase(t *testing.T) { if got.ReportDigest != "" { t.Fatalf("report_digest = %q, want empty: the migration carries no backfill", got.ReportDigest) } + // The offset has to survive where the digest does not, or the upgrade replays every line the + // previous run already surfaced. if got.ReportOffset != 61 { t.Fatalf("report_offset = %d, want the pre-migration row's 61 intact", got.ReportOffset) } @@ -523,10 +504,9 @@ func TestReportDigestColumnMigratesOntoAnExistingDatabase(t *testing.T) { } } -// Exercises the real usage-limit entry against a database holding rows written before -// hand could detect a limit. Deliberately no backfill: an empty retry stamp means "this -// task is not limited", which is the honest reading of every such row, and a value -// invented here would drive real steers into a pane off a schedule nothing observed. +// Exercises the real usage-limit entry against a database holding rows written before hand could +// detect a limit. Deliberately no backfill: an empty retry stamp means "this task is not limited", the +// honest reading of every such row, and an invented value would steer a pane off a schedule nobody saw. func TestUsageLimitColumnsMigrateOntoAnExistingDatabase(t *testing.T) { home := t.TempDir() diff --git a/internal/store/store.go b/internal/store/store.go index 428ec88..0e95bd1 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -61,14 +61,12 @@ type Task struct { // means leaves this false and is what MergeAnnounced records instead. MergeExecuted bool `json:"merged"` MergeExecutedAt string `json:"merged_at"` - // Durable so a watcher restart resumes exactly where it stopped instead of - // replaying every line the previous run already surfaced. ReportDigest - // fingerprints the bytes ReportOffset has consumed, because the offset alone - // cannot tell a report file rewritten in place from one nothing was appended - // to when the rewrite kept its length; see state.ReportCursor, which is the - // pair these two columns store. Empty on a row written before the column - // existed, and on one whose worker has yet to report a line. - ReportOffset int64 `json:"report_offset"` + // Durable so a watcher restart resumes exactly where it stopped instead of replaying every line + // the previous run already surfaced. + ReportOffset int64 `json:"report_offset"` + // Fingerprints the bytes ReportOffset has consumed, because the offset alone cannot tell a report + // file rewritten in place from one nothing was appended to when the rewrite kept its length; see + // state.ReportCursor, the pair these two columns store. Empty before the column, or before a report. ReportDigest string `json:"report_digest"` // A merge hand observed rather than performed. Distinct from MergeExecuted: // a restarted watcher needs to know the announcement went out even when @@ -88,56 +86,41 @@ type Task struct { // re-reading report history it has already consumed past ReportOffset. LastReportState string `json:"last_report_state"` LastReportNote string `json:"last_report_note"` - // Durable so a hand send message with no evidence it reached the pane - - // a composer still busy past the --wait bound, a failed send, a failed - // submit - leaves a trace instead of vanishing with the process that - // attempted it; the operator who ran that send is the only one who would - // otherwise know it was ever tried. Cleared on the next send that actually - // reaches the pane, whatever message that send carries. + // Durable so a send with no evidence it reached the pane - a composer busy past the + // --wait bound, a failed send, a failed submit - leaves a trace instead of vanishing + // with the process that tried it. Cleared by the next send that does reach the pane. SendUndeliveredMessage string `json:"send_undelivered_message"` SendUndeliveredAt string `json:"send_undelivered_at"` - // treehouse mints a fresh identity on every acquisition, so this - unlike - // Worktree, whose pool slot path is recycled - names the one lease this task - // holds and no other. Empty on a row written before the column existed, or by - // a treehouse that predates lease identities; see worktree.CheckCollision. + // treehouse mints a fresh identity per acquisition, so unlike Worktree, whose pool slot + // path is recycled, this names the one lease this task holds. Empty on a row older than + // the column or from a treehouse predating lease identities (worktree.CheckCollision). LeaseID string `json:"lease_id"` - // Set when the work is handed off and the decision to land it belongs to - // someone outside the fleet - an upstream maintainer, or a deliverable that - // is a report rather than a commit. Distinct from MergeExecuted and - // MergeAnnounced, which both assert the work landed: a delivered task is - // terminal without that claim, and stays distinguishable from a merged one - // afterwards (atqamz/secondhand#78). DeliveredReason is required, so the record - // says what was delivered and to whom rather than only that something was. + // Set when landing the work belongs outside the fleet - an upstream maintainer, or a + // deliverable that is a report, not a commit. Terminal without MergeExecuted's claim that + // it landed (atqamz/secondhand#78). Reason required, so the record says what and to whom. DeliveredAt string `json:"delivered_at"` DeliveredReason string `json:"delivered_reason"` - // When the pane this task currently occupies began, written by spawn and - // restamped by hand promote. A separate fact from StatusChangedAt, which the - // outage-dwell clock restamps for a pane it could not even reach: one field - // cannot mean both "this pane started here" and "the last herdr transition - // was observed here" (atqamz/secondhand#128). + // When this task's pane began, written by spawn and restamped by hand promote. Unlike + // StatusChangedAt, which the outage-dwell clock restamps for an unreachable pane, one field + // cannot mean both pane-start and last-observed transition (atqamz/secondhand#128). PaneStartedAt string `json:"pane_started_at"` - // The silence instant hand watch last fired `parked` against. Durable because - // a done or failed task's report file never grows again, so a re-derived latch - // lets every watcher restart re-fire against that same frozen instant and - // evict real history from the capped state/events.log (atqamz/secondhand#127). + // The silence instant hand watch last fired `parked` against. Durable: a terminal task's + // report file never grows, so a re-derived latch would re-fire that frozen instant every + // restart and evict real history from events.log (atqamz/secondhand#127). ParkedFiredFor string `json:"parked_fired_for"` - // The earliest instant hand watch may next try to resume a worker its harness - // stopped on a usage limit, and how many such attempts it has already made. - // Non-empty is what makes a task limited; the `limit` hold is the operator-visible - // projection of it. Durable because a re-derived schedule lets every watcher - // restart attempt immediately against an account that is still limited, which is - // the retry storm the whole mechanism is bounded to avoid, and because a restart - // that forgot the schedule would never resume the worker at all - // (atqamz/secondhand#136). - UsageLimitRetryAt string `json:"usage_limit_retry_at"` - UsageLimitAttempts int `json:"usage_limit_attempts"` + // The earliest instant hand watch may next try to resume a worker its harness stopped on a usage + // limit. Non-empty is what makes a task limited; the `limit` hold is the operator-visible + // projection of it. + UsageLimitRetryAt string `json:"usage_limit_retry_at"` + // How many such attempts have been made. Both are durable because a re-derived schedule lets every + // watcher restart attempt immediately against an account still limited - the retry storm this is + // bounded to avoid - and a restart that forgot it never resumes at all (atqamz/secondhand#136). + UsageLimitAttempts int `json:"usage_limit_attempts"` } -// Upstream is the "owner/repo" a fork project opens its PRs against, empty for -// a project that contributes to its own repo. A fork contribution has two -// repos - the fork hand pushes to, which URL names, and the upstream the PR -// lives on - and only a declared upstream lets hand tell that pair apart from -// a PR URL naming a repo nobody authorized (atqamz/secondhand#78). +// Upstream is the "owner/repo" a fork project opens its PRs against, empty when it contributes to +// its own repo. A fork has two repos, the one URL names and the one its PRs live on, and only a +// declared upstream tells that pair from an unauthorized one (atqamz/secondhand#78). type Project struct { Name string URL string @@ -145,10 +128,9 @@ type Project struct { Upstream string } -// Hold is its own row keyed by an arbitrary id, not a foreign key into task: -// the case it exists for is a question left open by work that has no task row -// behind it any more, because hand teardown already removed it. BlockedOn -// carries the id a HoldKindBlocked hold waits on; empty for HoldKindOperator. +// Hold is its own row keyed by an arbitrary id, not a foreign key into task: it exists for a +// question left open by work whose task row hand teardown already removed. BlockedOn carries +// the id a HoldKindBlocked hold waits on, empty for HoldKindOperator. type Hold struct { ID string `json:"id"` Kind string `json:"kind"` @@ -295,10 +277,9 @@ func (db *DB) setMeta(key, value string) error { return nil } -// taskColumnNames is the one list of the task table's columns. Everything a -// write needs is derived from it - the column list, the placeholders, the -// upsert's SET clause - so adding a column to `schema` and to taskValues below -// cannot reach one writer and silently miss another. +// The one list of the task table's columns. Everything a write needs is derived from it - +// the column list, the placeholders, the upsert's SET clause - so adding a column to +// `schema` and to taskValues below cannot reach one writer and silently miss another. var taskColumnNames = []string{ "id", "project", "kind", "harness", "model", "effort", "worktree", "brief", "herdr_session", "herdr_workspace_id", "herdr_tab_id", "herdr_pane_id", "pr", @@ -316,8 +297,8 @@ var ( taskUpsertSet = taskExcludedAssignments() ) -// taskExcludedAssignments builds the upsert's SET clause for every column but -// the primary key, which is what the conflict matched on. +// Builds the upsert's SET clause for every column but the primary key, which is what the +// conflict matched on. func taskExcludedAssignments() string { assignments := make([]string, 0, len(taskColumnNames)-1) for _, name := range taskColumnNames[1:] { @@ -326,7 +307,7 @@ func taskExcludedAssignments() string { return strings.Join(assignments, ", ") } -// taskValues is one task's columns in taskColumnNames order. +// One task's columns in taskColumnNames order. func taskValues(t Task) []any { return []any{ t.ID, t.Project, t.Kind, t.Harness, t.Model, t.Effort, t.Worktree, t.Brief, @@ -521,10 +502,9 @@ func (db *DB) ReadHold(id string) (Hold, bool, error) { return h, true, nil } -// ListHolds surfaces every row, whatever it holds: a caller that filtered -// here on kind or on BlockedOn being consistent with kind would let a row an -// external write left inconsistent silently disappear from "what is held" -// instead of being reported as a hold that needs attention. +// ListHolds surfaces every row, whatever it holds: filtering here on kind, or on BlockedOn +// being consistent with kind, would let a row an external write left inconsistent disappear +// from "what is held" instead of being reported as a hold that needs attention. func (db *DB) ListHolds() ([]Hold, error) { rows, err := db.sql.Query(`SELECT ` + holdColumns + ` FROM hold ORDER BY id`) if err != nil { diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 1b77c1a..a06bc57 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -201,11 +201,9 @@ func TestOpenImportsLegacyTaskFiles(t *testing.T) { } } -// A legacy file written before pane_started_at existed must land the value the -// schema migration's backfill would have given it: the import is an INSERT, so -// the backfill never runs over it, and an empty pane start slides parked's floor -// back to the row's creation - the scout's, for a task promoted before either -// mechanism existed. +// A legacy file written before pane_started_at existed must land the value the schema +// migration's backfill would have given it: the import is an INSERT the backfill never runs +// over, and an empty pane start slides parked's floor back to the row's creation. func TestLegacyImportBackfillsThePaneStart(t *testing.T) { for _, tc := range []struct { name string @@ -305,11 +303,9 @@ func TestOpenRefusesAnUnreadableLegacyFile(t *testing.T) { } } -// Every hand command opens the store, so the first contact with a legacy home -// is routinely several of them at once. The import spans a readdir, an insert -// and an archive rename, none of which sqlite serializes, so concurrent opens -// have to come out of it with each task imported exactly once and every legacy -// file archived - not a partial import and not a second copy of a task. +// Every hand command opens the store, so first contact with a legacy home is routinely +// several at once. The import spans a readdir, an insert and an archive rename, none of them +// serialized, so concurrent opens must still import each task once and archive every file. func TestConcurrentOpensImportALegacyHomeExactlyOnce(t *testing.T) { home := t.TempDir() const legacyTasks = 5 diff --git a/internal/watcher/events.go b/internal/watcher/events.go index 36eb52d..856f43d 100644 --- a/internal/watcher/events.go +++ b/internal/watcher/events.go @@ -11,31 +11,21 @@ import ( "github.com/atqamz/secondhand/internal/state" ) -// Kind values classify an Event for stdout/log routing. -// -// There is deliberately no bare "done" kind. A done announcement only exists once a -// worker's own done report is cross-checked against recorded evidence that the task -// landed - see doneVerified for what counts, which is deliberately not a question -// about the project's mode or the route the work took - so it is a verified -// KindReportDone. ClassifyStatus emits nothing for herdr's own done/idle split, which -// carries no task-outcome signal. "done" survives only as the state column that event -// writes (state.ReportDone), never as a kind. +// Kind values classify an Event for stdout/log routing. There is deliberately no bare "done" kind - +// a done announcement is a verified KindReportDone, see classifyReportDone - and "done" survives +// only as the state column that event writes (state.ReportDone). const ( KindIdleUnreported = "idle-unreported" KindBlocked = "blocked" KindFailed = "failed" KindStale = "stale" KindPRMerged = "pr-merged" - // KindPRNotRecorded and KindPRRecordUnknown are two different facts, kept as - // two greppable tokens: an auto-record that was attempted and did not - // complete - for any reason, refused validation through unreadable state - - // whose remedy is `hand pr`, which reconciles all of them rather than - // no-opping; versus one never attempted because another - // process held the task lock, where whether the PR got recorded is unknown - // and the only honest instruction is to check `hand status`. The split is by - // whether an attempt happened, never by cause, so a new cause needs no new - // kind. - KindPRNotRecorded = "pr-not-recorded" + // An auto-record attempted and not completed, for any reason from refused validation through + // unreadable state. The remedy is `hand pr`, which reconciles all of them rather than no-opping. + KindPRNotRecorded = "pr-not-recorded" + // Never attempted, because another process held the task lock: whether the PR got recorded is + // unknown, so the only honest instruction is to check `hand status`. Two greppable tokens because + // the split is by whether an attempt happened, never by cause - a new cause needs no new kind. KindPRRecordUnknown = "pr-record-unknown" KindReportWorking = "report-working" KindReportPaused = "report-paused" @@ -45,10 +35,9 @@ const ( KindReportFailed = "report-failed" KindReportMalformed = "report-malformed" KindParked = "parked" - // Three kinds for the usage-limit lifecycle, split by what an operator can do - // about each: the limit itself and the resume that ends it are bookkeeping a - // human has no part in, while `usage-limit-stuck` is the one that says the - // mechanism has run out of its own answers and needs someone. + // Three kinds for the usage-limit lifecycle, split by what an operator can do about each: the limit + // and the resume that ends it are bookkeeping a human has no part in, while `usage-limit-stuck` says + // the mechanism has run out of its own answers and needs someone. KindUsageLimit = "usage-limit" KindUsageLimitResumed = "usage-limit-resumed" KindUsageLimitStuck = "usage-limit-stuck" @@ -67,31 +56,25 @@ func KnownKinds() []string { } } -// NotifyFilter is the EventFilter for the watcher's in-process notify hook - -// see SPECS.md's "Notifying a supervisory agent with no session watching" for -// why its membership differs from --event's. report-blocked has to be listed -// even though blocked already is: it is the worker's own report-channel -// declaration that it is stuck, not the herdr transition, and ClassifyStatus -// suppresses idle-unreported once LastReportState is set - so a worker that -// reports blocked and then goes idle would otherwise notify no one. -// -// Of the three usage-limit kinds only usage-limit-stuck is here. A limit that clears -// on its own wakes nobody, by design: the resume needs no human, and notifying on -// every limit would make the fleet's loudest channel the one carrying its most -// routine event. +// NotifyFilter is the EventFilter for the watcher's in-process notify hook - see SPECS.md's +// "Notifying a supervisory agent with no session watching" for why its membership differs from +// --event's. func NotifyFilter() EventFilter { + // report-blocked is listed even though blocked already is: it is the worker's own report-channel + // declaration that it is stuck, not the herdr transition, and ClassifyStatus suppresses + // idle-unreported once LastReportState is set - so reporting then going idle would notify no one. return NewEventFilter([]string{ KindBlocked, KindReportBlocked, KindFailed, KindReportFailed, KindReportNeedsDecision, KindReportDone, + // Only usage-limit-stuck of the three limit kinds: a limit that clears on its own wakes nobody by + // design, since the resume needs no human and notifying on every limit would make the fleet's + // loudest channel the one carrying its most routine event. KindUsageLimitStuck, }) } -// EventFilter restricts which event kinds count as a wake for RunUntilEvent. The -// caller expresses it directly in terms of Kind rather than against a fixed -// actionable/progress split: report-working is exactly what distinguishes a -// wedged spawn from a slow one, so hardcoding it out would defeat the one case -// that most needs watching. A nil/empty filter matches every kind - the only -// behavior the streaming Run path ever has, and --until-event's default too. +// EventFilter restricts which event kinds count as a wake for RunUntilEvent, expressed directly in +// terms of Kind rather than against a fixed actionable/progress split: report-working is exactly +// what distinguishes a wedged spawn from a slow one, so hardcoding it out defeats the main case. type EventFilter map[string]bool // NewEventFilter builds a filter from caller-supplied kind names. An empty list @@ -109,14 +92,16 @@ func NewEventFilter(kinds []string) EventFilter { } func (f EventFilter) Matches(kind string) bool { + // A nil or empty filter matches every kind - the only behavior the streaming Run path ever has, + // and --until-event's default too. if len(f) == 0 { return true } return f[kind] } -// blockedReason is the only detail available: herdr reports agent_status without a -// free-text cause, so this mirrors herdr's own "blocked" state description. +// The only detail available: herdr reports agent_status without a free-text cause, so this mirrors +// herdr's own "blocked" state description. const blockedReason = "agent needs help" type Event struct { @@ -147,57 +132,42 @@ type TaskState struct { PersistedCursor state.ReportCursor PersistedPRMerged bool PersistedDoneVerified bool - // PersistedPaneID names the herdr pane every pane-anchored field below was - // cached against, so hand promote handing the task a new pane is detectable as - // such. No timestamp can stand in for it: RFC3339 is second-granular, so a - // restamp landing in the same second as this watcher's own last write is - // indistinguishable from no promote at all. + // Names the herdr pane every pane-anchored field below was cached against, so hand promote handing + // the task a new pane is detectable as such. No timestamp can stand in: RFC3339 is second-granular, + // so a restamp in the same second as this watcher's own last write looks like no promote at all. PersistedPaneID string - // PersistedChangedAt mirrors ChangedAt the same way, and PersistedChangedFor the - // status it was stamped for: a dwell clock that resumes from "now" never - // survives a fleet re-arming faster than it elapses, so ChangedAt is seeded from - // durable evidence on resume rather than reset - evidence that only holds while - // it still describes the status being dwelt in. + // Mirrors ChangedAt the same way, with PersistedChangedFor the status it was stamped for: a dwell + // clock resuming from "now" never survives a fleet re-arming faster than it elapses, so ChangedAt + // is seeded from durable evidence - evidence that holds only while it still describes that status. PersistedChangedAt time.Time PersistedChangedFor string LastReportState string - // LastReportNote is kept alongside LastReportState so a done report that only - // gains its completion evidence later can be re-announced with the same text a - // synchronous verification would have produced. + // Kept alongside LastReportState so a done report that only gains its completion evidence later + // can be re-announced with the same text a synchronous verification would have produced. LastReportNote string // DoneVerified makes the verified-done announcement idempotent across ticks, // and is persisted after the announcement so it stays idempotent across a // restart too. DoneVerified bool - // ParkedFiredFor is persisted, unlike the stale and unreachable latches: what - // makes re-deriving those safe is that their dwell clocks keep moving, so a - // restart costs one duplicate at most. A done or failed task's report file - // never grows again, so its silence instant is frozen and every restart - // re-fires against it - and state/events.log is capped, so those duplicates - // evict real history rather than merely repeating themselves. + // Persisted, unlike the stale and unreachable latches: their dwell clocks keep moving, so + // re-deriving costs one duplicate at most. A done or failed task's report file never grows again, + // so its silence instant is frozen, every restart re-fires, and capped events.log evicts history. ParkedFiredFor time.Time PersistedParkedFiredFor time.Time - // LimitRetryAt and LimitAttempts mirror the task's durable usage-limit schedule: - // non-zero LimitRetryAt is what makes the task limited, and the attempt count is - // what the backoff and the stuck bound are measured in. Both are persisted for the - // same reason ParkedFiredFor is, only sharper - a re-derived schedule would let - // every watcher restart attempt a resume immediately, against an account the last - // attempt just found still limited. + // Mirrors the task's durable usage-limit schedule: a non-zero retry instant is what makes the task + // limited, and the attempt count is what the backoff and the stuck bound are measured in. Persisted + // for ParkedFiredFor's reason, sharper - a re-derived schedule resumes against a still-limited account. LimitRetryAt time.Time LimitAttempts int PersistedLimitRetryAt time.Time PersistedLimitAttempts int - // LimitProbed records that this watcher has read the pane looking for a limit - // message at least once for this task. Deliberately not persisted, and the reason - // a watcher that starts up against an already-limited worker still finds it: the - // stop that stranded the worker happened before this process existed, so there is - // no transition left to detect on and the first sighting has to do it instead. + // Records that this watcher has read the pane looking for a limit message at least once for this + // task. Deliberately not persisted, and the reason a watcher starting against an already-limited + // worker still finds it: the stop predates this process, so a first sighting stands in for a transition. LimitProbed bool - // UnreachableFired claims an outage episode the same way Stale claims a - // silence episode: re-derived, never persisted. A restart mid-outage loses it - // and ClassifyUnreachable simply re-evaluates the dwell against durable - // evidence, so the safe failure mode is one duplicate announcement, never a - // suppressed one. + // Claims an outage episode the same way Stale claims a silence episode: re-derived, never + // persisted. A restart mid-outage loses it and ClassifyUnreachable re-evaluates the dwell against + // durable evidence, so the safe failure mode is one duplicate announcement, never a suppressed one. UnreachableFired bool } @@ -207,19 +177,9 @@ func NewTaskState(status herdr.Status, now time.Time) *TaskState { return &TaskState{Status: status, Probed: true, ChangedAt: now} } -// ClassifyStatus compares a freshly probed status against ts and returns an -// actionable event for the transitions SPECS.md calls out (idle-unreported, blocked, -// failed). Benign transitions (into working, repeated not-busy/blocked) update ts in -// place and return nil. -// -// herdr's idle and done are the same signal for hand's purposes: the pane stopped -// being busy. Neither says anything about whether the task actually finished - see -// herdr.Status's doc comment for why hand only ever observes done, not idle, for this -// transition in practice. A transition out of working/blocked into not-busy only fires -// KindIdleUnreported when nothing has explained the stop: no report at all, or the -// last report was still "working". Any other reported state (paused, blocked, -// needs-decision, done, failed) already explains the pane going quiet, so the -// transition is absorbed silently instead of raising a false alarm. +// ClassifyStatus compares a freshly probed status against ts and returns an actionable event for the +// transitions SPECS.md calls out (idle-unreported, blocked, failed). Benign transitions - into +// working, repeated not-busy or blocked - update ts in place and return nil. func ClassifyStatus(ts *TaskState, id string, status herdr.Status, probeErr error, now time.Time) *Event { if probeErr != nil { wasProbed := ts.Probed @@ -242,9 +202,15 @@ func ClassifyStatus(ts *TaskState, id string, status herdr.Status, probeErr erro ts.Stale = false switch { + // herdr's idle and done are one signal here: the pane stopped being busy, neither saying the task + // finished. See herdr.Status's doc for why hand only ever observes done, not idle, for this + // transition - and nothing at all is emitted for that done/idle split, which carries no outcome. case status.NotBusy(): if prevStatus == herdr.StatusWorking || prevStatus == herdr.StatusBlocked { ts.Blocked = false + // Only when nothing has explained the stop: no report at all, or a last report still + // "working". Any other reported state - paused, blocked, needs-decision, done, failed - + // already explains the pane going quiet, so the transition is absorbed instead of alarming. if ts.LastReportState == "" || ts.LastReportState == state.ReportWorking { return &Event{TaskID: id, Kind: KindIdleUnreported, Text: fmt.Sprintf("idle-unreported %s", id)} } @@ -271,26 +237,24 @@ func ClassifyStale(ts *TaskState, id string, now time.Time, threshold time.Durat return &Event{TaskID: id, Kind: KindStale, Text: fmt.Sprintf("stale %s", id)} } -// ClassifyUnreachable covers the one outage ClassifyStatus's immediate branch -// can't: a task whose very first sighting - or first sighting after a restart - -// finds its pane unreachable, which leaves ts.Probed false with no prior "was -// probed" edge to fire on. Gating on threshold rather than firing on sight is -// what makes a blink produce nothing: a pane that answers again before the dwell -// matures never reaches here, since ClassifyStatus's success path clears -// ts.Probed back to true first. ts.ChangedAt is reused as the outage's dwell -// clock rather than adding a new one, so it rides the same restart-safe seeding -// every other dwell in this package already gets - see statusChangeSeed. Reusing -// KindFailed rather than a new kind keeps this the same fact ClassifyStatus's -// immediate branch already announces: the pane is unreachable, whichever tick -// caught it first. +// ClassifyUnreachable covers the one outage ClassifyStatus's immediate branch cannot: a task whose +// very first sighting - or first sighting after a restart - finds its pane unreachable, which leaves +// ts.Probed false with no prior "was probed" edge to fire on. func ClassifyUnreachable(ts *TaskState, id string, now time.Time, threshold time.Duration) *Event { + // ClassifyStatus's success path clears ts.Probed back to true, so a pane that answers again before + // the dwell matures never reaches it. if ts.Probed || ts.UnreachableFired { return nil } + // Gating on the threshold rather than firing on sight is what makes a blink produce nothing. + // ts.ChangedAt is reused as the outage's dwell clock rather than adding a new one, so it rides the + // same restart-safe seeding every other dwell in this package gets - see statusChangeSeed. if now.Sub(ts.ChangedAt) < threshold { return nil } ts.UnreachableFired = true + // KindFailed rather than a new kind: this is the same fact ClassifyStatus's immediate branch + // announces - the pane is unreachable, whichever tick caught it first. return &Event{TaskID: id, Kind: KindFailed, Text: fmt.Sprintf("failed %s", id)} } @@ -300,11 +264,9 @@ type ParkedBounds struct { Other time.Duration } -// A non-positive bound means unconfigured rather than zero-tolerance. done/failed -// get their own tier rather than the blanket exemption this used to be: the -// status file being torn down is what actually severs a task from steering, not -// the worker's own last word on the matter, so a done/failed worker still -// attached to a pane is silence like any other and gets bounded the same way. +// done and failed get their own tier rather than the blanket exemption this used to be: the status +// file being torn down is what actually severs a task from steering, not the worker's own last word, +// so a done or failed worker still attached to a pane is silence like any other and is bounded too. func parkedBound(lastState string, bounds ParkedBounds) (bound time.Duration, exempt bool) { switch lastState { case state.ReportDone, state.ReportFailed: @@ -314,6 +276,7 @@ func parkedBound(lastState string, bounds ParkedBounds) (bound time.Duration, ex default: bound = bounds.Other } + // A non-positive bound means unconfigured, not zero-tolerance. if bound <= 0 { return 0, true } @@ -353,12 +316,12 @@ func ClassifyPRMerged(ts *TaskState, id string, merged bool) *Event { return &Event{TaskID: id, Kind: KindPRMerged, Text: fmt.Sprintf("pr-merged %s", id)} } -// ClassifyReportLine turns one classified report line into an event and records -// it as ts.LastReportState so a subsequent idle transition can consult it. A -// malformed line is surfaced rather than dropped, but doesn't overwrite the last -// known report state since free text alone explains nothing. +// ClassifyReportLine turns one classified report line into an event and records it as +// ts.LastReportState so a subsequent idle transition can consult it. func ClassifyReportLine(home string, ts *TaskState, t state.Task, line state.ReportLine) *Event { id := t.ID + // A malformed line is surfaced rather than dropped, but does not overwrite the last known report + // state, since free text alone explains nothing. if line.Malformed { return &Event{TaskID: id, Kind: KindReportMalformed, Text: fmt.Sprintf("malformed report %s: %s", id, line.Raw), Reason: line.Raw} } @@ -382,9 +345,9 @@ func ClassifyReportLine(home string, ts *TaskState, t state.Task, line state.Rep return nil } -// classifyReportDone never trusts a worker's own belief that it's finished: without -// independent completion evidence the event is marked unverified so watch -// consumers surface "worker says done" without treating it as confirmed fact. +// Never trusts a worker's own belief that it is finished: without independent completion evidence +// the event is marked unverified, so watch consumers surface "worker says done" without treating it +// as confirmed fact. func classifyReportDone(home string, ts *TaskState, t state.Task, line state.ReportLine) *Event { verified := doneVerified(home, ts, t) if verified { @@ -393,11 +356,11 @@ func classifyReportDone(home string, ts *TaskState, t state.Task, line state.Rep return &Event{TaskID: t.ID, Kind: KindReportDone, Text: doneText(t.ID, line.Note, verified), Reason: line.Note, Verified: verified} } -// ClassifyDeferredDone covers the ordinary ordering, where a worker reports done -// before the evidence exists: the done line was already consumed and can't be -// re-read, so once evidence shows up on a later tick the verified event fires from -// the state the report left behind. Idempotent - it fires at most once per task. +// ClassifyDeferredDone covers the ordinary ordering, where a worker reports done before the evidence +// exists: the done line was already consumed and cannot be re-read, so once evidence shows up on a +// later tick the verified event fires from the state the report left behind. func ClassifyDeferredDone(home string, ts *TaskState, t state.Task) *Event { + // Idempotent: at most one deferred announcement per task. if ts.DoneVerified || ts.LastReportState != state.ReportDone { return nil } @@ -416,23 +379,18 @@ func doneText(id, note string, verified bool) string { return fmt.Sprintf("%s %s: %s", text, id, note) } -// doneVerified asks one question: is there recorded evidence, not authored by the -// worker, that this task landed? What counts is a property of the deliverable, not -// of the route or the project's mode - a ship task lands by merging, however that -// merge happened, and a scout task lands by producing the data//report.md that -// hand promote itself requires. -// -// So the ship check reads t.MergeExecuted first and asks nothing further: it is -// only ever written after a merge actually happened, whether through a PR or a -// local fast-forward that leaves no PR at all. A recorded PR the watcher's own -// poll saw merged is that same evidence arriving the other way. Narrowing this to -// one route is what made the check silently always-false for a whole class of -// task twice. +// Asks one question: is there recorded evidence, not authored by the worker, that this task landed? +// What counts is a property of the deliverable, not of the route or the project's mode. Narrowing it +// to one route is what made this check silently always-false for a whole class of task twice. func doneVerified(home string, ts *TaskState, t state.Task) bool { switch t.Kind { case state.KindShip: + // A ship task lands by merging, however the merge happened: t.MergeExecuted is only ever + // written after one actually did, through a PR or a local fast-forward leaving no PR at all. A + // recorded PR the watcher's own poll saw merged is that same evidence arriving the other way. return t.MergeExecuted || (t.PR != "" && ts.PRMerged) case state.KindScout: + // A scout task lands by producing the data//report.md hand promote itself requires. _, err := os.Stat(filepath.Join(home, "data", t.ID, "report.md")) return err == nil } diff --git a/internal/watcher/events_test.go b/internal/watcher/events_test.go index 9da0678..5e8e03b 100644 --- a/internal/watcher/events_test.go +++ b/internal/watcher/events_test.go @@ -11,10 +11,9 @@ import ( "github.com/atqamz/secondhand/internal/state" ) -// notBusyStatuses covers herdr's two spellings of "pane stopped being busy" - see -// herdr.Status's doc comment for why idle and done must be classified identically: -// hand's headless polling model observes done, essentially always, never idle, for -// this transition against real herdr. +// Covers herdr's two spellings of "pane stopped being busy" - see herdr.Status's doc for why idle +// and done must be classified identically: hand's headless polling model observes done, essentially +// always, never idle, for this transition against real herdr. var notBusyStatuses = []herdr.Status{herdr.StatusIdle, herdr.StatusDone} func TestClassifyStatusWorkingToNotBusyFiresIdleUnreportedWhenNoTerminalReport(t *testing.T) { @@ -174,11 +173,9 @@ func TestClassifyStaleSkipsUnprobedTasks(t *testing.T) { } } -// TestClassifyUnreachableFiresOnceAfterTheDwellForATaskFirstSeenDown covers the -// case ClassifyStatus's immediate branch cannot: a task whose very first -// sighting has ts.Probed already false, so there is no "was probed" edge to -// fire on. This is the shape resumeTaskState leaves an unreachable-at-first- -// sighting task in. +// Covers the case ClassifyStatus's immediate branch cannot: a task whose very first sighting has +// ts.Probed already false, so there is no "was probed" edge to fire on. This is the shape +// resumeTaskState leaves an unreachable-at-first-sighting task in. func TestClassifyUnreachableFiresOnceAfterTheDwellForATaskFirstSeenDown(t *testing.T) { now := time.Now() threshold := 5 * time.Minute @@ -402,9 +399,8 @@ func TestClassifyReportDoneVerifiedOnlyWithCompletionEvidence(t *testing.T) { } } -// TestClassifyDeferredDoneFiresOnceWhenEvidenceArrivesAfterTheReport covers the -// ordinary ordering: the worker reports done, the PR is merged only afterwards, -// and the done line is long consumed by the time the merge is observed. +// Covers the ordinary ordering: the worker reports done, the PR is merged only afterwards, and the +// done line is long consumed by the time the merge is observed. func TestClassifyDeferredDoneFiresOnceWhenEvidenceArrivesAfterTheReport(t *testing.T) { home := t.TempDir() task := state.Task{ID: "task-1", Kind: state.KindShip, PR: "https://github.com/a/b/pull/1"} diff --git a/internal/watcher/ownership.go b/internal/watcher/ownership.go index cf900f5..1c6e4af 100644 --- a/internal/watcher/ownership.go +++ b/internal/watcher/ownership.go @@ -29,14 +29,9 @@ func OwnerPath(homeDir string) string { return filepath.Join(state.Dir(homeDir), "watch.pid") } -// Acquire makes hand watch a singleton per fleet home, returning the release to -// defer. -// -// Ownership is the flock on state/watch.pid, never the pid the file holds: the -// kernel drops an flock when the holder dies, so a crashed watcher leaves -// nothing stale to clear and no liveness heuristic can lock a home out of -// watching itself. The pid is recorded inside the lock only so a refusal can -// name the incumbent and takeover can signal it. +// Acquire makes hand watch a singleton per fleet home, returning the release to defer. Ownership is +// the flock on state/watch.pid, never the pid the file holds: the kernel drops an flock when the +// holder dies, so nothing stale is left to clear and no liveness heuristic locks a home out. func Acquire(homeDir string, takeover bool) (func(), error) { path := OwnerPath(homeDir) if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { @@ -58,6 +53,8 @@ func Acquire(homeDir string, takeover bool) (func(), error) { } } + // The pid is recorded inside the lock only so a refusal can name the incumbent and takeover can + // signal it. if err := recordOwner(file); err != nil { releaseOwner(file) return nil, err @@ -65,9 +62,9 @@ func Acquire(homeDir string, takeover bool) (func(), error) { return func() { releaseOwner(file) }, nil } -// contend decides what a lock another live watcher holds means. The pid is read -// after the lock attempt failed, so it belongs to a process that still held the -// lock a moment ago rather than to some long-dead predecessor. +// Decides what a lock another live watcher holds means. The pid is read after the lock attempt +// failed, so it belongs to a process that still held the lock a moment ago rather than to some +// long-dead predecessor. func contend(file *os.File, takeover bool) error { pid := readOwner(file) if !takeover { @@ -105,9 +102,8 @@ func lockOwner(file *os.File) error { return syscall.Flock(int(file.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) } -// releaseOwner clears the pid before dropping the lock, so an operator reading -// state/watch.pid on an unwatched home finds nothing rather than the number of a -// process that has exited. +// Clears the pid before dropping the lock, so an operator reading state/watch.pid on an unwatched +// home finds nothing rather than the number of a process that has exited. func releaseOwner(file *os.File) { _ = file.Truncate(0) _ = syscall.Flock(int(file.Fd()), syscall.LOCK_UN) @@ -124,15 +120,15 @@ func recordOwner(file *os.File) error { return nil } -// readOwner reports the recorded pid, or 0 for anything it cannot read as one. -// The terminating newline is required: the incumbent truncates before it writes, -// so a read racing that write sees an empty or partial value, and only a -// terminated line proves the whole pid reached disk. A wrong pid here would be +// Reports the recorded pid, or 0 for anything it cannot read as one - a wrong pid here would be // handed to Kill. func readOwner(file *os.File) int { buf := make([]byte, 32) n, _ := file.ReadAt(buf, 0) line, _, terminated := strings.Cut(string(buf[:n]), "\n") + // The terminating newline is required: the incumbent truncates before it writes, so a read racing + // that write sees an empty or partial value, and only a terminated line proves the whole pid + // reached disk. if !terminated { return 0 } diff --git a/internal/watcher/ownership_test.go b/internal/watcher/ownership_test.go index 01e35f2..685b4b8 100644 --- a/internal/watcher/ownership_test.go +++ b/internal/watcher/ownership_test.go @@ -94,9 +94,8 @@ func TestReleaseClearsTheRecordedPid(t *testing.T) { release() } -// deadPid returns a pid that was real and is now gone, so a test about a crashed -// watcher is about an actually dead process rather than an invented number that -// might collide with a live one. +// Returns a pid that was real and is now gone, so a test about a crashed watcher is about an +// actually dead process rather than an invented number that might collide with a live one. func deadPid(t *testing.T) int { t.Helper() cmd := exec.Command(os.Args[0], "-test.run=^$") diff --git a/internal/watcher/usagelimit.go b/internal/watcher/usagelimit.go index 293f772..64901cc 100644 --- a/internal/watcher/usagelimit.go +++ b/internal/watcher/usagelimit.go @@ -11,87 +11,72 @@ import ( "github.com/atqamz/secondhand/internal/state" ) -// The bounds on resuming a usage-limited worker. The failure mode being designed -// against is a retry storm against an account that is still limited, so every -// attempt is spent deliberately: -// -// - limitFloor is how long the first attempt waits when the harness named no reset -// instant at all, and the base of the backoff between later attempts. -// - limitBackoffCap keeps a long limit from being probed more than hourly. -// - limitMaxWait caps how far a named reset instant can push an attempt out, so a -// misparsed or absurd prediction cannot strand the worker indefinitely. Note -// that a genuinely week-long limit is not probed hourly for a week: each attempt -// re-reads the harness's own fresh refusal and reschedules from the reset it -// names, so the pattern is roughly one attempt a day, not one an hour. -// - limitSkew puts the attempt just past the named instant rather than exactly on -// it, since a prediction landing on the boundary would otherwise burn an attempt -// a few seconds early every time. -// - limitStuckAfter is where the mechanism admits it is out of answers and says so -// once on the notify channel. It keeps trying afterwards - a weekly limit is -// real and does eventually lift - but no longer quietly. +// The bounds on resuming a usage-limited worker. The failure mode designed against is a retry storm +// against an account that is still limited, so every attempt is spent deliberately. const ( - limitFloor = 10 * time.Minute + // How long the first attempt waits when the harness named no reset instant at all, and the base of + // the backoff between later attempts. + limitFloor = 10 * time.Minute + // Keeps a long limit from being probed more than hourly. limitBackoffCap = time.Hour - limitMaxWait = 24 * time.Hour - limitSkew = time.Minute + // Caps how far a named reset instant can push an attempt out, so a misparsed or absurd prediction + // cannot strand the worker. A week-long limit is still not probed hourly for a week: each attempt + // re-reads the harness's own fresh refusal and reschedules from it, so roughly one attempt a day. + limitMaxWait = 24 * time.Hour + // Puts the attempt just past the named instant rather than exactly on it, since a prediction landing + // on the boundary would otherwise burn an attempt a few seconds early every time. + limitSkew = time.Minute + // Where the mechanism admits it is out of answers and says so once on the notify channel. It keeps + // trying afterwards - a weekly limit is real and does eventually lift - but no longer quietly. limitStuckAfter = 6 ) -// limitReadLines is how much scrollback a limit check reads. The refusal is the last -// thing a limited harness prints, so this only has to outlast whatever the harness -// draws under it. +// How much scrollback a limit check reads. The refusal is the last thing a limited harness prints, so +// this only has to outlast whatever the harness draws under it. const limitReadLines = 60 -// limitResumeMessage is the steer that ends a limit. It is deliberately a plain -// instruction rather than a bare "continue": a worker whose limit has lifted needs to -// know why it is being poked, and a worker whose limit has not lifted answers with a -// fresh refusal - which is the observation the next attempt is scheduled from. +// The steer that ends a limit, deliberately a plain instruction rather than a bare "continue": a worker +// whose limit has lifted needs to know why it is being poked, and one whose limit has not answers with a +// fresh refusal - the observation the next attempt is scheduled from. const limitResumeMessage = "Your previous turn stopped on a usage limit. The limit may have lifted now. Resume the task from where it stopped." -// limitPane is the herdr surface the usage-limit machinery needs, narrowed so a test -// can drive the whole detect-attempt-resume cycle without a herdr daemon. +// The herdr surface the usage-limit machinery needs, narrowed so a test can drive the whole +// detect-attempt-resume cycle without a herdr daemon. type limitPane interface { PaneRead(paneID string, lines int) (string, error) PaneSendText(paneID, text string) error PaneSendKeys(paneID string, keys ...string) error } -// classifyUsageLimit is the whole usage-limit lifecycle for one task on one tick: -// detect a harness that stopped on a limit, resume it once the limit plausibly -// lifted, and let go the moment the worker is running again. -// -// It is not one more condition on the poll loop's own list of things that might be -// wrong. Whether a limit is even detectable is a harness capability -// (harness.SupportsUsageLimit), and a harness that declines it - every harness but -// claude today - costs a map lookup and returns here, having read no pane and sent -// nothing. Adding another harness is an entry in that catalogue, not a branch here. -// -// justStopped must be computed before ClassifyStatus consumes the transition: it is -// what makes detection edge-triggered rather than a per-tick pane read. The other -// edge is ts.LimitProbed, which covers the case no transition can - a watcher -// starting up against a worker that was already stranded before this process existed. +// The whole usage-limit lifecycle for one task on one tick: detect a harness that stopped on a limit, +// resume it once the limit plausibly lifted, and let go the moment the worker is running again. func classifyUsageLimit(cfg Config, client limitPane, ts *TaskState, t state.Task, pane herdr.Pane, status herdr.Status, probeErr error, justStopped bool, now time.Time, errOut io.Writer) *Event { // A pane that will not answer PaneGet cannot be read or steered either, and // ClassifyUnreachable already owns that fact. if probeErr != nil { return nil } + // Whether a limit is even detectable is a harness capability, so a harness that declines it - every + // harness but claude today - costs a map lookup and returns having read no pane and sent nothing. + // Adding another harness is an entry in that catalogue, not a branch here. if !harness.SupportsUsageLimit(pane.Agent) { return nil } if !ts.LimitRetryAt.IsZero() { return continueUsageLimit(cfg, client, ts, t, pane, status, now, errOut) } + // justStopped is computed before ClassifyStatus consumes the transition: it is what makes detection + // edge-triggered rather than a per-tick pane read. ts.LimitProbed is the other edge, covering what no + // transition can - a watcher starting against a worker already stranded before this process existed. if !status.NotBusy() || (!justStopped && ts.LimitProbed) { return nil } return detectUsageLimit(cfg, client, ts, t, pane, now, errOut) } -// detectUsageLimit reads the stopped pane once and, if the harness stopped on a -// limit, records the schedule that will resume it. A worker whose report channel -// already explains the stop is left alone: a done or failed worker is not waiting on -// quota, and steering one would restart work that is over. +// Reads the stopped pane once and, if the harness stopped on a limit, records the schedule that will +// resume it. A worker whose report channel already explains the stop is left alone: a done or failed +// worker is not waiting on quota, and steering one would restart work that is over. func detectUsageLimit(cfg Config, client limitPane, ts *TaskState, t state.Task, pane herdr.Pane, now time.Time, errOut io.Writer) *Event { if reportEndsTask(ts.LastReportState) { return nil @@ -122,11 +107,9 @@ func detectUsageLimit(cfg Config, client limitPane, ts *TaskState, t state.Task, } } -// continueUsageLimit runs for a task already known to be limited. The clear check -// comes first and runs every tick, not only when an attempt is due: the limit can end -// for reasons this package had no part in - an operator `hand send`, a human typing in -// the pane - and a worker that is visibly running again must not keep collecting -// attempts against it. +// Runs for a task already known to be limited. The clear check comes first and runs every tick, not only +// when an attempt is due: the limit can end for reasons this package had no part in - an operator +// `hand send`, a human typing in the pane - and a visibly running worker must not keep collecting attempts. func continueUsageLimit(cfg Config, client limitPane, ts *TaskState, t state.Task, pane herdr.Pane, status herdr.Status, now time.Time, errOut io.Writer) *Event { if status == herdr.StatusWorking || status == herdr.StatusBlocked { return clearUsageLimit(cfg, ts, t, errOut) @@ -142,30 +125,25 @@ func continueUsageLimit(cfg Config, client limitPane, ts *TaskState, t state.Tas return attemptUsageLimitResume(cfg, client, ts, t, pane, now, errOut) } -// attemptUsageLimitResume steers the pane and schedules the next attempt. The pane is -// read first: the freshest refusal on screen is the harness's own latest prediction of -// when its quota returns, and scheduling from it is what keeps a genuinely long limit -// from being probed on the backoff's much shorter clock. -// -// The attempt itself is the observation. Nothing here decides whether the limit is -// over - the next tick's clear check does that, by seeing whether the pane started -// working. -// -// The steer takes the same `send:` lock hand send holds, because two writers -// racing one composer is the lost steer atqamz/secondhand#102 traced. TryLock, never -// Lock: a poll tick must not block behind an operator's whole --wait, and an operator -// send landing right now is itself the thing that ends the limit. A busy lock spends -// no attempt - the schedule is left untouched, so the next tick finds it due again. +// Steers the pane and schedules the next attempt. The attempt itself is the observation: nothing here +// decides whether the limit is over - the next tick's clear check does that, by seeing whether the pane +// started working. func attemptUsageLimitResume(cfg Config, client limitPane, ts *TaskState, t state.Task, pane herdr.Pane, now time.Time, errOut io.Writer) *Event { + // The same `send:` lock hand send holds, because two writers racing one composer is the lost + // steer atqamz/secondhand#102 traced. TryLock, never Lock: a tick must not block behind an operator's + // whole --wait, and an operator send landing right now is itself the thing that ends the limit. release, err := state.TryLock(cfg.Home, "send:"+t.ID) if err != nil { if !errors.Is(err, state.ErrLockBusy) { _, _ = fmt.Fprintf(errOut, "watch: lock send %s failed: %v\n", t.ID, err) } + // A busy lock spends no attempt: the schedule is left untouched, so the next tick finds it due. return nil } defer release() + // Read first: the freshest refusal on screen is the harness's own latest prediction of when its quota + // returns, and scheduling from it keeps a genuinely long limit off the backoff's much shorter clock. reset := time.Time{} if text, err := client.PaneRead(t.Herdr.PaneID, limitReadLines); err != nil { _, _ = fmt.Fprintf(errOut, "watch: read pane for %s failed: %v\n", t.ID, err) @@ -195,8 +173,8 @@ func attemptUsageLimitResume(cfg Config, client limitPane, ts *TaskState, t stat } } -// clearUsageLimit forgets the schedule and the operator-visible hold together, so the -// two cannot disagree about whether a task is still waiting on quota. +// Forgets the schedule and the operator-visible hold together, so the two cannot disagree about whether +// a task is still waiting on quota. func clearUsageLimit(cfg Config, ts *TaskState, t state.Task, errOut io.Writer) *Event { attempts := ts.LimitAttempts ts.LimitRetryAt = time.Time{} @@ -225,10 +203,9 @@ func attemptCount(attempts int) string { return fmt.Sprintf("%d attempts", attempts) } -// nextLimitRetry picks when to try next: never earlier than the backoff allows, never -// earlier than the instant the harness itself named, and never further out than -// limitMaxWait. A named reset is only ever a prediction, so it moves the attempt - it -// never decides that the limit is over. +// Picks when to try next: never earlier than the backoff allows, never earlier than the instant the +// harness itself named, and never further out than limitMaxWait. A named reset is only ever a +// prediction, so it moves the attempt - it never decides that the limit is over. func nextLimitRetry(reset time.Time, attempts int, now time.Time) time.Time { wait := limitBackoff(attempts) if !reset.IsZero() { @@ -242,9 +219,9 @@ func nextLimitRetry(reset time.Time, attempts int, now time.Time) time.Time { return now.Add(wait) } -// limitBackoff doubles from limitFloor up to limitBackoffCap. The shift is bounded -// before it happens rather than after, since a large attempt count would otherwise -// overflow the duration into a negative wait - a task retried on every tick. +// Doubles from limitFloor up to limitBackoffCap. The shift is bounded before it happens rather than +// after, since a large attempt count would otherwise overflow the duration into a negative wait - a +// task retried on every tick. func limitBackoff(attempts int) time.Duration { if attempts < 0 { attempts = 0 @@ -266,9 +243,9 @@ func reportEndsTask(lastState string) bool { return lastState == state.ReportDone || lastState == state.ReportFailed } -// steerPane is the same two-call steer hand send performs: text into the composer, -// then Enter to submit it. Split the same way, because text that arrived but was never -// submitted is a distinct failure from text that never arrived. +// The same two-call steer hand send performs: text into the composer, then Enter to submit it. Split +// the same way, because text that arrived but was never submitted is a distinct failure from text that +// never arrived. func steerPane(client limitPane, paneID, message string) error { if err := client.PaneSendText(paneID, message); err != nil { return fmt.Errorf("send message: %w", err) @@ -279,24 +256,17 @@ func steerPane(client limitPane, paneID, message string) error { return nil } -// limitReason is what an operator sees in hand status's held block, refreshed on -// every attempt: which attempt the mechanism is on and when it next tries. The hold is -// the projection of the schedule, so it carries no fact the schedule does not. +// What an operator sees in hand status's held block, refreshed on every attempt: which attempt the +// mechanism is on and when it next tries. The hold is the projection of the schedule, so it carries no +// fact the schedule does not. func limitReason(ts *TaskState) string { return fmt.Sprintf("harness stopped on a usage limit; %s made, next try %s", attemptCount(ts.LimitAttempts), ts.LimitRetryAt.UTC().Format(time.RFC3339)) } -// writeLimitHold makes the limit visible where an operator already looks, and blocks -// `hand spawn` from reusing the id out from under a worker that is only waiting on -// quota. A failure is loud but never fatal: the schedule in task state is what -// actually resumes the worker, and losing its projection must not cost the resume. -// -// A hold of another kind on the same id is left standing for the same reason a machine -// clear spares one: an operator's own question is theirs to answer, and overwriting it -// with this projection would destroy it once the limit ends and the row is cleared. -// Nothing is lost by yielding - the id is already held against `hand spawn`, and the -// schedule that resumes the worker never reads the hold. +// Makes the limit visible where an operator already looks, and blocks `hand spawn` from reusing the id +// out from under a worker that is only waiting on quota. A failure is loud but never fatal: the schedule +// in task state is what resumes the worker, and losing its projection must not cost the resume. func writeLimitHold(home, id string, ts *TaskState, errOut io.Writer) { h := state.Hold{ ID: id, diff --git a/internal/watcher/usagelimit_test.go b/internal/watcher/usagelimit_test.go index 9a5be19..ba65d1a 100644 --- a/internal/watcher/usagelimit_test.go +++ b/internal/watcher/usagelimit_test.go @@ -16,15 +16,13 @@ import ( const claudeLimitText = "Claude usage limit reached. Your limit will reset at 3pm (UTC)." -// limitedPaneAgent is the harness herdr reports for a pane the usage-limit capability -// covers. Held as a constant so a test asserting the capability's gate reads against -// the same value the fake serves. +// The harness herdr reports for a pane the usage-limit capability covers. Held as a constant so a test +// asserting the capability's gate reads against the same value the fake serves. const limitedPaneAgent = "claude" -// paneScript arms the fake herdr with what a usage-limit check reads and where its -// steers are recorded, and returns the log the test asserts against. An empty agent is -// how a test says "herdr has not classified this pane", which is what every test -// predating the usage-limit check gets by default. +// Arms the fake herdr with what a usage-limit check reads and where its steers are recorded, and returns +// the log the test asserts against. An empty agent is how a test says "herdr has not classified this +// pane", which is what every test predating the usage-limit check gets by default. func paneScript(t *testing.T, agent, paneText string) (paneLog string) { t.Helper() dir := t.TempDir() @@ -66,10 +64,9 @@ func limitHold(t *testing.T, home, id string) (state.Hold, bool) { return h, exists } -// setLimitRetryAt rewrites the durable retry stamp, which is how a test makes an -// attempt due without waiting one out. Dropping the tracking map alongside it is the -// restart the stamp exists for: the watcher that scheduled the attempt is gone, and the -// one that resumes has nothing but this column to learn from. +// Rewrites the durable retry stamp, which is how a test makes an attempt due without waiting one out. +// Dropping the tracking map alongside it is the restart the stamp exists for: the watcher that scheduled +// the attempt is gone, and the one that resumes has nothing but this column to learn from. func setLimitRetryAt(t *testing.T, home, id string, at time.Time) { t.Helper() task := readTask(t, home, id) @@ -79,10 +76,9 @@ func setLimitRetryAt(t *testing.T, home, id string, at time.Time) { } } -// TestTickResumesALimitedWorkerAndLetsGoWhenItRuns is the behavior atqamz/secondhand#136 -// asks for, end to end through tick: a worker whose harness stopped on a usage limit is -// detected, recorded, steered once its schedule comes due, and released the moment it is -// running again. Recognising the message is only the first of the four. +// The behavior atqamz/secondhand#136 asks for, end to end through tick: a worker whose harness stopped +// on a usage limit is detected, recorded, steered once its schedule comes due, and released the moment +// it is running again. Recognising the message is only the first of the four. func TestTickResumesALimitedWorkerAndLetsGoWhenItRuns(t *testing.T) { statusFile := filepath.Join(t.TempDir(), "status") setStatus(t, statusFile, "working") @@ -155,10 +151,9 @@ func TestTickResumesALimitedWorkerAndLetsGoWhenItRuns(t *testing.T) { } } -// The other half of the same behavior: a worker that stopped for any other reason must -// come out of a tick untouched. A mechanism that steers on a stop it cannot explain is -// worse than no mechanism, since the steer lands in a pane whose worker had its own -// reason to be quiet. +// The other half of the same behavior: a worker that stopped for any other reason must come out of a +// tick untouched. A mechanism that steers on a stop it cannot explain is worse than no mechanism, since +// the steer lands in a pane whose worker had its own reason to be quiet. func TestTickLeavesAWorkerThatStoppedForAnotherReasonAlone(t *testing.T) { statusFile := filepath.Join(t.TempDir(), "status") setStatus(t, statusFile, "working") @@ -443,10 +438,9 @@ func (p *steerFailingPane) PaneSendText(string, string) error { return errors.Ne func (p *steerFailingPane) PaneSendKeys(string, ...string) error { return nil } -// An attempt is the same steer hand send performs and takes the same lock, so a send in -// flight is never interleaved into the composer it is already writing. The attempt is -// deferred rather than spent: the schedule stays due, and the next tick either steers or -// finds the send has ended the limit for it. +// An attempt is the same steer hand send performs and takes the same lock, so a send in flight is never +// interleaved into the composer it is already writing. The attempt is deferred rather than spent: the +// schedule stays due, and the next tick either steers or finds the send has ended the limit for it. func TestAnAttemptYieldsToASendHoldingTheLock(t *testing.T) { home := setupWatcherHome(t, state.Task{ID: "task-1", Project: "nsr", Kind: state.KindShip, Herdr: state.Herdr{PaneID: "p1"}}) release, err := state.TryLock(home, "send:task-1") @@ -477,10 +471,9 @@ func TestAnAttemptYieldsToASendHoldingTheLock(t *testing.T) { } } -// The hold is only a projection of the schedule, and an operator's hold on the same id -// is a question of their own. Overwriting it would not merely hide that question: the -// clear at the end of the limit matches on kind, so the row - operator hold and all - -// would be deleted outright once the worker ran again. +// The hold is only a projection of the schedule, and an operator's hold on the same id is a question of +// their own. Overwriting it would not merely hide that question: the clear at the end of the limit +// matches on kind, so the row - operator hold and all - would be deleted once the worker ran again. func TestALimitLeavesAnOperatorHoldOnTheSameIDStanding(t *testing.T) { home := setupWatcherHome(t, state.Task{ID: "task-1", Project: "nsr", Kind: state.KindShip, Herdr: state.Herdr{PaneID: "p1"}}) operatorHold := state.Hold{ diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index 18a56e1..4570caa 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -31,11 +31,9 @@ type Config struct { // Timeout bounds RunUntilEvent only. Zero blocks until an event arrives. Timeout time.Duration ParkedBounds ParkedBounds - // EventFilter bounds which kinds reach out, whichever writer that is: handleEvent - // applies it to every event it writes there, on the Run path as much as the - // RunUntilEvent one. Keeping the streaming path unfiltered is cmd/watch.go's - // doing, not this package's - it rejects --event without --until-event, so Run - // never receives a filter from the CLI. + // Bounds which kinds reach out, whichever writer that is: handleEvent applies it to every event on + // the Run path as much as the RunUntilEvent one. Keeping Run unfiltered is cmd/watch.go's doing, + // not this package's: it rejects --event without --until-event, so Run never gets a CLI filter. EventFilter EventFilter } @@ -45,11 +43,9 @@ var ErrNoEvent = errors.New("no event") // an exit from RunUntilEvent always means the whole fleet was actually watched. var ErrArmFailed = errors.New("could not arm") -// Run blocks, polling herdr agent states at cfg.PollInterval until ctx is -// canceled. It returns nil on clean cancellation, or an error if herdr is -// unreachable at startup. out receives the actionable event stream documented -// in SPECS.md; errOut receives internal diagnostics (list/log failures), -// keeping the two streams separable per the stdout/stderr contract. +// Run blocks, polling herdr agent states at cfg.PollInterval until ctx is canceled, returning nil on +// clean cancellation or an error if herdr is unreachable at startup. out receives the actionable +// event stream SPECS.md documents, errOut internal diagnostics, per the stdout/stderr split. func Run(ctx context.Context, cfg Config, out, errOut io.Writer) error { client, err := connect(ctx) if err != nil { @@ -71,12 +67,9 @@ func Run(ctx context.Context, cfg Config, out, errOut io.Writer) error { } } -// RunUntilEvent blocks until a tick produces events, writes them to out, and -// returns nil - the exit is the delivery, since it's the one signal a supervisory -// agent's background-task runner already honors. The startup state is never -// delivered: two ticks take a silent baseline first, so an already-done worker -// isn't mistaken for a fresh transition. Baseline events still reach -// events.log, just not stdout. +// RunUntilEvent blocks until a tick produces events, writes them to out and returns nil - the exit is +// the delivery, since it is the one signal a supervisory agent's background-task runner already +// honors. func RunUntilEvent(ctx context.Context, cfg Config, out, errOut io.Writer) error { if cfg.Timeout > 0 { var cancel context.CancelFunc @@ -94,6 +87,9 @@ func RunUntilEvent(ctx context.Context, cfg Config, out, errOut io.Writer) error } states := make(map[string]*TaskState) + // The startup state is never delivered: two ticks take a silent baseline first, so an already-done + // worker is not mistaken for a fresh transition. Baseline events still reach events.log, just not + // stdout, which is what io.Discard as out means here. tick(ctx, cfg, client, states, io.Discard, errOut) tick(ctx, cfg, client, states, io.Discard, errOut) @@ -118,15 +114,15 @@ func RunUntilEvent(ctx context.Context, cfg Config, out, errOut io.Writer) error } } -// connect races the reachability probe against ctx because unbounded, a wedged -// herdr daemon blocks RunUntilEvent's --timeout from ever starting to count. -// Losing that race is ErrNoEvent for the same reason probeAllTasks's is: the -// window closed during arming, which is exit 4 wherever in arming it happens. +// Races the reachability probe against ctx because unbounded, a wedged herdr daemon blocks +// RunUntilEvent's --timeout from ever starting to count. func connect(ctx context.Context) (*herdr.Client, error) { client := herdr.NewClient() done := make(chan error, 1) go func() { _, err := client.WorkspaceList(); done <- err }() select { + // Losing the race is ErrNoEvent for the same reason probeAllTasks's is: the window closed during + // arming, which is exit 4 wherever in arming it happens. case <-ctx.Done(): if errors.Is(ctx.Err(), context.DeadlineExceeded) { return nil, fmt.Errorf("%w: timed out reaching herdr", ErrNoEvent) @@ -140,11 +136,8 @@ func connect(ctx context.Context) (*herdr.Client, error) { } } -// probeAllTasks confirms every active task's pane answers before RunUntilEvent -// arms, since an unprobed task would otherwise wait out the timeout with no -// distinguishing signal. Losing the race against ctx is ErrNoEvent, not -// ErrArmFailed: the window is simply over and no single task can be named as the -// cause the way ErrArmFailed's exit promises. +// Confirms every active task's pane answers before RunUntilEvent arms, since an unprobed task would +// otherwise wait out the timeout with no distinguishing signal. func probeAllTasks(ctx context.Context, home string, client *herdr.Client) error { tasks, err := state.List(home) if err != nil { @@ -161,6 +154,8 @@ func probeAllTasks(ctx context.Context, home string, client *herdr.Client) error done <- nil }() select { + // ErrNoEvent, not ErrArmFailed: the window is simply over, and no single task can be named as the + // cause the way ErrArmFailed's exit promises. case <-ctx.Done(): if errors.Is(ctx.Err(), context.DeadlineExceeded) { return fmt.Errorf("%w: timed out probing tasks before arming", ErrNoEvent) @@ -188,27 +183,25 @@ func tick(ctx context.Context, cfg Config, client *herdr.Client, states map[stri pane, probeErr := client.PaneGet(t.Herdr.PaneID) status := pane.AgentStatus - // Tracking is keyed by task identity, not by ID: an ID torn down and - // respawned between two ticks is a different task, and inheriting the - // previous run's TaskState would suppress the new task's verified done - // forever - syncTaskState writes that inherited done_verified onto the fresh - // row, making the suppression durable - and absorb its first unexplained - // stop. Same hazard as a surviving report channel, one layer in; see + // Tracking is keyed by task identity, not by ID: an ID torn down and respawned between two + // ticks is a different task. Same hazard as a surviving report channel, one layer in - see // state.Delete for that half. ts, tracked := states[t.ID] + // Inheriting the previous run's TaskState would suppress the new task's verified done forever - + // syncTaskState writes that inherited done_verified onto the fresh row, making the suppression + // durable - and absorb its first unexplained stop. if tracked && ts.CreatedAt != t.CreatedAt { tracked = false } if !tracked { - // herdr.StatusUnknown stands in for "no real status observed yet", so the - // eventual recovery reads as an ordinary transition rather than inventing a - // prior status. Probed=false starts ClassifyUnreachable's dwell clock - // immediately instead of waiting for a second failed probe to notice this - // task at all. + // herdr.StatusUnknown stands in for "no real status observed yet", so the eventual recovery + // reads as an ordinary transition rather than inventing a prior status. if probeErr != nil { status = herdr.StatusUnknown } ts = resumeTaskState(t, status, now) + // False starts ClassifyUnreachable's dwell clock immediately, instead of waiting for a second + // failed probe to notice this task at all. ts.Probed = probeErr == nil states[t.ID] = ts continue @@ -252,6 +245,9 @@ func tick(ctx context.Context, cfg Config, client *herdr.Client, states map[stri if e := classifyUsageLimit(cfg, client, ts, t, pane, status, probeErr, justStopped, now, errOut); e != nil { handleEvent(cfg, e, out, errOut) } + // Last, after every event this tick produced has been announced: a marker persisted before its + // line is emitted would, if the process died in between, suppress an announcement nothing can + // re-derive. A duplicate line is a far cheaper failure than a silently dropped one. syncTaskState(cfg.Home, t.ID, ts, now, errOut) } @@ -262,11 +258,9 @@ func tick(ctx context.Context, cfg Config, client *herdr.Client, states map[stri } } -// Every fact resumeTaskState restores comes from durable state, never re-derived -// from current evidence: evidence that landed while the watcher was down (hand -// merge writing merged, say) would otherwise look like an announcement that -// already went out. SPECS.md's "What survives a hand watch restart" enumerates -// what is deliberately re-derived instead, and why that is safe. +// Every fact restored here comes from durable state, never re-derived from current evidence: what +// landed while the watcher was down (hand merge writing merged, say) would otherwise look like an +// announcement that already went out. SPECS.md's "What survives a hand watch restart" owns the rest. func resumeTaskState(t state.Task, status herdr.Status, now time.Time) *TaskState { changedAt := statusChangeSeed(t, status, now) ts := NewTaskState(status, changedAt) @@ -291,10 +285,9 @@ func resumeTaskState(t state.Task, status herdr.Status, now time.Time) *TaskStat return ts } -// limitRetrySeed reads back the instant a limited worker may next be tried. An -// unparseable stamp seeds unlimited, which loses the schedule rather than inventing -// one: the next stop edge or first probe re-detects the limit from the pane, whereas a -// stamp guessed at here would drive real steers off a value nothing wrote. +// Reads back the instant a limited worker may next be tried. An unparseable stamp seeds unlimited, +// losing the schedule rather than inventing one: the next stop edge or first probe re-detects the limit +// from the pane, whereas a stamp guessed at here would drive real steers off a value nothing wrote. func limitRetrySeed(t state.Task) time.Time { parsed, err := time.Parse(time.RFC3339, t.UsageLimitRetryAt) if err != nil { @@ -310,10 +303,9 @@ func limitRetryStamp(retryAt time.Time) string { return retryAt.UTC().Format(time.RFC3339) } -// parkedFiredSeed reads back the silence instant parked last fired against. An -// unparseable stamp seeds unfired rather than failing the resume: one duplicate -// event is the same failure direction the whole classifier already prefers over a -// suppressed one. +// Reads back the silence instant parked last fired against. An unparseable stamp seeds unfired +// rather than failing the resume: one duplicate event is the same failure direction the whole +// classifier already prefers over a suppressed one. func parkedFiredSeed(t state.Task) time.Time { parsed, err := time.Parse(time.RFC3339Nano, t.ParkedFiredFor) if err != nil { @@ -322,10 +314,9 @@ func parkedFiredSeed(t state.Task) time.Time { return parsed } -// parkedFiredStamp is nanosecond-precision because the value is a report file's -// mtime compared for exact equality, and RFC3339's whole seconds would round it -// down into an instant no later mtime ever matches - re-firing on every restart, -// which is the bug. +// Nanosecond precision because the value is a report file's mtime compared for exact equality, and +// RFC3339's whole seconds would round it down into an instant no later mtime ever matches - +// re-firing on every restart, which is the bug. func parkedFiredStamp(fired time.Time) string { if fired.IsZero() { return "" @@ -333,24 +324,20 @@ func parkedFiredStamp(fired time.Time) string { return fired.UTC().Format(time.RFC3339Nano) } -// forgetPaneScopedCache drops the cached facts hand promote invalidated: it gives -// the task a new herdr pane while keeping created_at, so tick's identity check -// never fires, yet every pane-anchored fact cached here describes a pane the task -// no longer has. The trigger is the pane itself changing, not the newly observed -// status differing - a ship whose first probe reads the status the scout last held -// raises no transition at all - and not any restamped timestamp either, which is -// both too eager (a resume reseeds the dwell to now for reasons of its own) and too -// blunt (a restamp inside one RFC3339 second of this watcher's own write is -// invisible). +// Drops the cached facts hand promote invalidated: it gives the task a new herdr pane while keeping +// created_at, so tick's identity check never fires, yet every pane-anchored fact cached here +// describes a pane the task no longer has. func forgetPaneScopedCache(ts *TaskState, t state.Task, now time.Time) { - // Compared against the persisted mirror, not the live flag: the watcher only ever - // sets the flag true, so a disk value that has gone false since this watcher last - // wrote true is such a rewrite - whereas a flag set true earlier in this very tick - // is simply not persisted yet, and syncTaskState's OR is how it gets there. + // Compared against the persisted mirror, not the live flag: the watcher only ever sets the flag + // true, so a disk value gone false since this watcher wrote true is such a rewrite - while a flag + // set true earlier in this very tick is simply not persisted yet, which syncTaskState's OR fixes. if !t.DoneVerified && ts.PersistedDoneVerified { ts.DoneVerified = false ts.PersistedDoneVerified = false } + // The trigger is the pane changing, not the newly observed status differing - a ship whose first + // probe reads the status the scout last held raises no transition at all - and not a restamped + // timestamp, too eager (a resume reseeds the dwell) and too blunt (same-second restamps vanish). if t.Herdr.PaneID == ts.PersistedPaneID { return } @@ -359,44 +346,36 @@ func forgetPaneScopedCache(ts *TaskState, t state.Task, now time.Time) { ts.ChangedAt = seed ts.PersistedChangedAt = seed ts.PersistedChangedFor = t.StatusChangedFor - // Reset after the seed above, which asks what status the cached dwell describes. - // StatusUnknown is the sentinel a ship's first probe is diffed against, matching - // neither the working nor the blocked branch, so that probe reads as the baseline - // a first sighting always is rather than as a transition out of the scout's status. + // Reset after the seed above, which asks what status the cached dwell describes. StatusUnknown + // matches neither the working nor the blocked branch, so a ship's first probe reads as the + // baseline a first sighting always is rather than as a transition out of the scout's status. ts.Status = herdr.StatusUnknown ts.Stale = false ts.Blocked = false - // False, exactly as tick seeds a task first sighted with an unreachable pane: the - // ship's first probe of its new pane is a first sighting, so an unreachable one - // dwells under ClassifyUnreachable's threshold instead of firing `failed` on - // sight. Carrying the old pane's true would fire that no-dwell `failed` off a - // blink, on the strength of a probe that only ever described the scout's pane. + // False, exactly as tick seeds a task first sighted with an unreachable pane: an unreachable first + // probe of the new pane dwells under ClassifyUnreachable's threshold instead of firing `failed` on + // sight, which the old pane's true would do off a blink, on a probe describing the scout's pane. ts.Probed = false - // A latch claiming the old pane's outage has nothing to say about the new one; - // left true it would sit inert until the next probe failure resets Probed to - // false anyway, but a fresh pane deserves a fresh episode on purpose, not by - // accident of that ordering. + // A latch claiming the old pane's outage has nothing to say about the new one. Left true it would + // sit inert until the next probe failure reset Probed anyway, but a fresh pane deserves a fresh + // episode on purpose, not by accident of that ordering. ts.UnreachableFired = false - // A usage limit is the harness's, and the new pane runs a new harness process with - // its own quota state. Carrying the schedule over would steer the fresh pane on a - // clock the scout's refusal set. The mirrors are re-read from the promoted row - // rather than zeroed alongside it, so the columns get written clear here in the one - // case hand promote did not already clear them itself. + // A usage limit is the harness's, and the new pane runs a new harness process with its own quota + // state. Carrying the schedule over would steer the fresh pane on a clock the scout's refusal set. ts.LimitRetryAt = time.Time{} ts.LimitAttempts = 0 ts.LimitProbed = false + // Re-read from the promoted row rather than zeroed alongside the rest, so the columns get written + // clear here in the one case hand promote did not already clear them itself. ts.PersistedLimitRetryAt = limitRetrySeed(t) ts.PersistedLimitAttempts = t.UsageLimitAttempts ts.LastReportState = t.LastReportState ts.LastReportNote = t.LastReportNote } -// tailReport classifies whatever report lines have arrived since ts.ReportCursor, -// before ClassifyStatus runs for this tick, so a report that lands in the same -// poll as a herdr idle transition is already reflected in ts.LastReportState when -// the idle-vs-idle-unreported decision is made. A line carrying exactly one -// embedded PR URL auto-records it, subject to the same validation hand pr -// enforces; more than one URL, or a PR already on record, is left alone. +// Classifies whatever report lines have arrived since ts.ReportCursor, before ClassifyStatus runs +// for this tick, so a report landing in the same poll as a herdr idle transition is already +// reflected in ts.LastReportState when the idle-vs-idle-unreported decision is made. func tailReport(ctx context.Context, cfg Config, ts *TaskState, t state.Task, out, errOut io.Writer) state.Task { path := state.ReportPath(cfg.Home, t.ID) lines, cursor, err := state.TailReport(path, ts.ReportCursor) @@ -409,6 +388,8 @@ func tailReport(ctx context.Context, cfg Config, ts *TaskState, t state.Task, ou if e := ClassifyReportLine(cfg.Home, ts, t, line); e != nil { handleEvent(cfg, e, out, errOut) } + // A line carrying exactly one embedded PR URL auto-records it, subject to the same validation + // hand pr enforces; more than one URL, or a PR already on record, is left alone. if t.PR == "" { if urls := state.FindPRURLs(line.Raw); len(urls) == 1 { if err := autoRecordPR(ctx, cfg.Home, t, urls[0]); err != nil { @@ -424,11 +405,10 @@ func tailReport(ctx context.Context, cfg Config, ts *TaskState, t state.Task, ou return t } -// statusChangeSeed answers how long a task has already been dwelling in status, so -// a restart does not reset a dwell that has been real all along. StatusChangedAt -// is only evidence about the status it was stamped for; a task with no observed -// transition at all has been dwelling since CreatedAt. +// Answers how long a task has already been dwelling in status, so a restart does not reset a dwell +// that has been real all along. func statusChangeSeed(t state.Task, status herdr.Status, now time.Time) time.Time { + // StatusChangedAt is only evidence about the status it was stamped for. if t.StatusChangedAt != "" { if t.StatusChangedFor != string(status) { return now @@ -437,18 +417,16 @@ func statusChangeSeed(t state.Task, status herdr.Status, now time.Time) time.Tim return parsed } } + // A task with no observed transition at all has been dwelling since CreatedAt. if parsed, err := time.Parse(time.RFC3339, t.CreatedAt); err == nil { return parsed } return now } -// reportEvidenceTime floors the report file's mtime at the instant the task's -// current pane started, because hand promote leaves the scout's report file - and -// so its mtime - untouched while clearing the last-report state that had the scout's -// silence under the long done/failed bound. Unfloored, a ship seconds old inherits -// the scout's whole silence, now measured against the short bound, and fires parked -// immediately. +// Floors the report file's mtime at the instant the task's current pane started, because hand +// promote leaves the scout's report file - and so its mtime - untouched while clearing the +// last-report state that had the scout's silence under the long done/failed bound. func reportEvidenceTime(home string, t state.Task) (time.Time, error) { started, err := paneStartTime(t) if err != nil { @@ -461,21 +439,21 @@ func reportEvidenceTime(home string, t state.Task) (time.Time, error) { } return started, nil } + // Unfloored, a ship seconds old inherits the scout's whole silence, now measured against the short + // bound, and fires parked immediately. if mtime := info.ModTime(); mtime.After(started) { return mtime, nil } return started, nil } -// paneStartTime is when the task's current pane started, as spawn and hand promote -// each recorded it. It deliberately no longer reads StatusChangedAt, which the -// outage-dwell clock restamps for a pane it could not even reach and which would -// slide this floor forward by up to a full bound of real report silence. The -// schema migration and the legacy import both backfill the column, so an empty -// stamp means a row nothing in hand wrote, and CreatedAt is the honest floor for -// it. +// When the task's current pane started, as spawn and hand promote each recorded it. Deliberately no +// longer StatusChangedAt, which the outage-dwell clock restamps for a pane it could not even reach, +// sliding this floor forward by up to a full bound of real report silence. func paneStartTime(t state.Task) (time.Time, error) { stamp, field := t.PaneStartedAt, "pane_started_at" + // The schema migration and the legacy import both backfill the column, so an empty stamp means a + // row nothing in hand wrote, and CreatedAt is the honest floor for it. if stamp == "" { stamp, field = t.CreatedAt, "created_at" } @@ -493,24 +471,22 @@ func lastReportLine(ts *TaskState) string { return fmt.Sprintf("%s: %s", ts.LastReportState, ts.LastReportNote) } -// announceAutoRecordFailure makes an auto-record that didn't happen a durable -// lifecycle fact on the event stream and in events.log, alongside the stderr -// diagnostic: the line is consumed and the offset moves on either way, so an -// unrecorded URL that only reached a long-running watcher's stderr would be lost. -// It is deliberately not a Pending Decision - that slot holds the worker's own -// question, and upserting by task ID there would erase one. -// -// The event kind is the outcome, since that token is what an operator greps -// events.log for: an attempt that did not complete is pr-not-recorded whatever -// stopped it, while losing the task lock leaves the outcome unknown and must not -// claim otherwise. The kind says only that much; the appended error text is what -// says why, so neither has to stand in for the other. +// Makes an auto-record that did not happen a durable lifecycle fact on the event stream and in +// events.log, alongside the stderr diagnostic: the line is consumed and the offset moves on either +// way, so an unrecorded URL that only reached a long-running watcher's stderr would be lost. func announceAutoRecordFailure(cfg Config, t state.Task, url string, err error, out, errOut io.Writer) { + // Deliberately not a Pending Decision: that slot holds the worker's own question, and upserting by + // task ID there would erase one. kind, outcome := KindPRNotRecorded, "failed" + // The kind is the outcome, the token an operator greps events.log for: an attempt that did not + // complete is pr-not-recorded whatever stopped it, while losing the task lock leaves the outcome + // unknown and must not claim otherwise. if errors.Is(err, errLockContended) { kind, outcome = KindPRRecordUnknown, "skipped" } _, _ = fmt.Fprintf(errOut, "watch: auto-record PR for %s %s: %v\n", t.ID, outcome, err) + // The kind says only that much; the appended error text is what says why, so neither has to stand + // in for the other. handleEvent(cfg, &Event{ TaskID: t.ID, Kind: kind, @@ -519,10 +495,9 @@ func announceAutoRecordFailure(cfg Config, t state.Task, url string, err error, }, out, errOut) } -// flattenError renders err's whole cause on one line. An event is one line on -// stdout and one entry in events.log; the errors reaching here wrap gh's -// stderr verbatim, which is routinely multi-line for auth and network -// failures. The stderr diagnostic above keeps the original formatting. +// Renders err's whole cause on one line: an event is one line on stdout and one entry in events.log, +// while the errors reaching here wrap gh's stderr verbatim, routinely multi-line for auth and +// network failures. The stderr diagnostic above keeps the original formatting. func flattenError(err error) string { var parts []string for _, line := range strings.FieldsFunc(err.Error(), func(r rune) bool { return r == '\n' || r == '\r' }) { @@ -533,10 +508,9 @@ func flattenError(err error) string { return strings.Join(parts, "; ") } -// autoRecordPR routes a worker-supplied URL through hand pr's own validation -// before it can reach task state: a URL that survives here is what `hand merge` -// later hands to `gh pr merge`, so a PR belonging to some other repo the worker -// merely mentioned must be refused, not recorded. +// Routes a worker-supplied URL through hand pr's own validation before it can reach task state: a +// URL that survives here is what `hand merge` later hands to `gh pr merge`, so a PR belonging to +// some other repo the worker merely mentioned must be refused, not recorded. func autoRecordPR(ctx context.Context, home string, t state.Task, url string) error { proj, exists, err := project.Find(home, t.Project) if err != nil { @@ -554,19 +528,15 @@ func autoRecordPR(ctx context.Context, home string, t state.Task, url string) er return recordAutoPR(home, t.ID, url) } -// recordAutoPR is race-safe against a concurrent explicit `hand pr` call or -// another tick's auto-record. The task lock is non-blocking for the same reason -// syncTaskState's is: this runs in the poll loop, which owes every other task a -// timely tick and its own ctx a prompt exit that flock cannot honor, while hand -// merge and hand promote hold the same task lock across gh and git round-trips. -// -// So the "already recorded" no-op has two halves. Holding the lock, it re-reads -// and no-ops if the PR arrived first. Denied the lock, it re-reads anyway: the -// likeliest holder is the `hand pr` recording this very URL, and treating that -// as a failed auto-record would announce a problem that fixed itself. +// Race-safe against a concurrent explicit `hand pr` call or another tick's auto-record. func recordAutoPR(home, id, url string) error { + // The task lock is non-blocking for the same reason syncTaskState's is: this runs in the poll loop, + // which owes every other task a timely tick and its own ctx a prompt exit flock cannot honor, + // while hand merge and hand promote hold the same task lock across gh and git round-trips. unlock, err := state.TryLock(home, "task:"+id) if err != nil { + // Denied the lock, it re-reads anyway: the likeliest holder is the `hand pr` recording this very + // URL, and treating that as a failed auto-record would announce a problem that fixed itself. if errors.Is(err, state.ErrLockBusy) { return recordedByLockHolder(home, id, url) } @@ -578,6 +548,7 @@ func recordAutoPR(home, id, url string) error { if err != nil { return fmt.Errorf("read task %s: %w", id, err) } + // Holding the lock, the "already recorded" no-op re-reads and no-ops if the PR arrived first. if t.PR != "" { return nil } @@ -588,43 +559,31 @@ func recordAutoPR(home, id, url string) error { return nil } -// errLockContended marks an auto-record the watcher declined rather than -// attempted, so callers can tell "refused this URL" from "never got to try". +// Marks an auto-record the watcher declined rather than attempted, so callers can tell "refused this +// URL" from "never got to try". var errLockContended = errors.New("task locked by another process") -// recordedByLockHolder decides what a lost race to the task lock means. If the -// URL is already on record the holder did the work, so there is nothing to say. -// Otherwise the outcome is genuinely unknown - the holder may be mid-write - and -// the report line is consumed either way, so it stays loud and says only that, -// rather than claiming a failure or naming a remedy that may be a no-op. A task -// whose state won't read says exactly that instead of sending the operator to -// hand status, which reads the same unreadable file. +// Decides what a lost race to the task lock means. func recordedByLockHolder(home, id, url string) error { t, err := state.Read(home, id) if err != nil { + // A task whose state will not read says exactly that, instead of sending the operator to hand + // status, which reads the same unreadable file. return fmt.Errorf("%w, and its state could not be read: %v", errLockContended, err) } + // The URL already on record means the holder did the work, so there is nothing to say. if t.PR == url { return nil } + // Otherwise the outcome is genuinely unknown - the holder may be mid-write - and the report line is + // consumed either way, so this stays loud and says only that, rather than claiming a failure or + // naming a remedy that may be a no-op. return fmt.Errorf("%w that may be recording it - confirm with: hand status %s", errLockContended, id) } -// syncTaskState writes back the bookkeeping hand watch owns on a task - how far -// its report file is consumed, whether this watcher's own gh poll already -// announced the PR merged, and whether the verified done already went out - so a -// restart neither replays report lines nor re-announces, nor silently skips, an -// announcement it can no longer re-derive. -// -// It runs last, after every event this tick produced has been announced: a marker -// persisted before its line is emitted would, if the process died in between, -// suppress an announcement nothing can re-derive. A duplicate line is a far -// cheaper failure than a silently dropped one. -// -// The lock is non-blocking on purpose. hand merge holds the same task lock across -// gh round-trips, and the poll loop - which owes every other task a timely tick, -// and its own ctx a prompt exit that flock can't honor - must not queue behind it. -// Everything written here is re-derivable, so a skipped write just retries. +// Writes back the bookkeeping hand watch owns on a task - how far its report file is consumed, +// whether this watcher's own gh poll announced the PR merged, whether the verified done went out - so +// a restart neither replays report lines nor re-announces, nor skips, what it cannot re-derive. func syncTaskState(home, id string, ts *TaskState, now time.Time, errOut io.Writer) { if ts.ReportCursor == ts.PersistedCursor && ts.PRMerged == ts.PersistedPRMerged && ts.DoneVerified == ts.PersistedDoneVerified && ts.ChangedAt.Equal(ts.PersistedChangedAt) && @@ -633,6 +592,9 @@ func syncTaskState(home, id string, ts *TaskState, now time.Time, errOut io.Writ return } + // Non-blocking on purpose: hand merge holds the same task lock across gh round-trips, and the poll + // loop - which owes every other task a timely tick, and its own ctx a prompt exit flock cannot + // honor - must not queue behind it. Everything written here is re-derivable, so a skip retries. unlock, err := state.TryLock(home, "task:"+id) if err != nil { if !errors.Is(err, state.ErrLockBusy) { @@ -647,9 +609,9 @@ func syncTaskState(home, id string, ts *TaskState, now time.Time, errOut io.Writ _, _ = fmt.Fprintf(errOut, "watch: read task %s failed: %v\n", id, err) return } - // A promote may have landed since this tick's state.List. Writing the cached - // values back would erase its restamp and leave the disk value matching what - // this watcher persisted, so no later tick would find anything to forget either. + // A promote may have landed since this tick's state.List. Writing the cached values back would + // erase its restamp and leave the disk value matching what this watcher persisted, so no later + // tick would find anything to forget either. forgetPaneScopedCache(ts, t, now) t.ReportOffset = ts.ReportCursor.Offset @@ -693,9 +655,9 @@ func handleEvent(cfg Config, e *Event, out, errOut io.Writer) { notifyEvent(cfg.Home, e, errOut) } -// notifyEvent is NotifyFilter's own consumer of the classified event stream - -// see SPECS.md's "Notifying a supervisory agent with no session watching" for -// why an unconfigured config/notify stays silent while a failed send is loud. +// NotifyFilter's own consumer of the classified event stream - see SPECS.md's "Notifying a +// supervisory agent with no session watching" for why an unconfigured config/notify stays silent +// while a failed send is loud. func notifyEvent(home string, e *Event, errOut io.Writer) { if !NotifyFilter().Matches(e.Kind) { return diff --git a/internal/watcher/watcher_test.go b/internal/watcher/watcher_test.go index 1ae413c..92ed808 100644 --- a/internal/watcher/watcher_test.go +++ b/internal/watcher/watcher_test.go @@ -20,30 +20,20 @@ import ( "github.com/atqamz/secondhand/internal/store" ) -// writeFakeHerdr fakes every herdr command a tick can make: real herdr answers the -// query commands with a JSON envelope carrying a non-null result on stdout and exit 0, -// and reports failures as an envelope error rather than bare stderr -// (internal/herdr/client.go's call doc comment), which this fake mirrors for -// success and diverges from for the unexpected-args arm - a bare stderr line -// and exit 1, so a call shape no test anticipated fails loudly instead of -// parsing. "pane get" reads its status from statusFile so a test can drive -// transitions between ticks; failure paths belong to -// internal/herdr/client_test.go. -// -// PANE_AGENT is empty unless a test sets it, which is what keeps every test written -// before the usage-limit check from reading a pane: an unclassified pane names no -// harness, so no harness capability applies to it. - -// paneGoneStatus drives the fake into herdr's failure shape for `pane get`: an error -// envelope on stdout with exit code 0. A fake that exited nonzero would also reach -// ClassifyStatus's probeErr branch, but through the client's empty-stdout path rather -// than the envelope check that runs ahead of the exit status - and this is the shape -// real herdr uses, which is the one that check exists for. +// Drives the fake into herdr's failure shape for `pane get` - an error envelope on stdout with exit 0, +// the shape the envelope check exists for. Exiting nonzero would reach ClassifyStatus's probeErr +// branch too, but through the client's empty-stdout path rather than that check, which runs first. const paneGoneStatus = "pane-gone" +// Fakes the two query commands a tick makes, mirroring real herdr: a JSON envelope with a non-null +// result on stdout and exit 0, failures as an envelope error rather than bare stderr, per +// internal/herdr/client.go's call doc. Those failure paths belong to internal/herdr/client_test.go. func writeFakeHerdr(t *testing.T, statusFile string) { t.Helper() bin := t.TempDir() + // The unexpected-args arm deliberately diverges - a bare stderr line and exit 1 - so a call shape + // no test anticipated fails loudly instead of parsing. "pane get" reads its status from statusFile, + // so a test can drive transitions between ticks. script := `#!/bin/sh case "$1 $2" in "workspace list") @@ -60,6 +50,9 @@ case "$1 $2" in printf '{"id":"cli:1","error":{"code":"not_found","message":"pane p1 not found"}}' exit 0 fi + # PANE_AGENT is empty unless a test sets it, which is what keeps every test written before the + # usage-limit check from reading a pane: an unclassified pane names no harness, so no harness + # capability applies to it. printf '{"id":"cli:1","result":{"pane":{"pane_id":"p1","agent_status":"%s","agent":"%s"}}}' "$status" "$PANE_AGENT" ;; "pane read") @@ -85,23 +78,21 @@ esac t.Setenv("STATUS_FILE", statusFile) } -// writeFakeGh fakes `gh pr view --json state`, the only gh call a tick makes -// (watcher.go's ghutil.PRIsMerged). Real gh prints that JSON object on stdout -// with exit 0 and prefixes its own warnings on stderr, so the fake emits a -// stderr line too: PRIsMerged reads stdout alone, and a CombinedOutput -// regression there must fail this watcher path as well, not only -// internal/ghutil/pr_test.go. +// Fakes `gh pr view --json state`, the only gh call a tick makes (watcher.go's ghutil.PRIsMerged), +// which real gh answers with that JSON object on stdout and exit 0. func writeFakeGh(t *testing.T, prState string) { writeFakeGhWithHook(t, prState, "") } -// writeFakeGhWithHook runs hook (a shell snippet) before the gh double answers. -// project.ValidatePR shells out to gh before the auto-record takes the task lock, -// so this is the only place a test can mutate task state at the one instant that -// matters: after tick's own state.List snapshot, before the auto-record re-reads. +// Runs hook (a shell snippet) before the gh double answers. project.ValidatePR shells out to gh +// before the auto-record takes the task lock, so this is the only place a test can mutate task state +// at the instant that matters: after tick's state.List snapshot, before the auto-record re-reads. func writeFakeGhWithHook(t *testing.T, prState, hook string) { t.Helper() bin := t.TempDir() + // Real gh prefixes its own warnings on stderr and PRIsMerged reads stdout alone, so the fake emits + // a stderr line too: a CombinedOutput regression there must fail this watcher path as well, not + // only internal/ghutil/pr_test.go. script := "#!/bin/sh\necho 'Warning: gh version is out of date' >&2\n" + hook + "\nprintf '{\"state\":\"" + prState + "\"}'\n" if err := os.WriteFile(filepath.Join(bin, "gh"), []byte(script), 0o755); err != nil { t.Fatal(err) @@ -109,9 +100,8 @@ func writeFakeGhWithHook(t *testing.T, prState, hook string) { t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH")) } -// registerProject gives a watcher home the two things the auto-record path's -// validation reads: a registry entry and a clone whose origin remote names the -// repo a reported PR URL has to belong to. +// Gives a watcher home the two things the auto-record path's validation reads: a registry entry and a +// clone whose origin remote names the repo a reported PR URL has to belong to. func registerProject(t *testing.T, home, name, remote string) { t.Helper() clonePath := filepath.Join(home, "projects", name) @@ -130,9 +120,9 @@ func registerProject(t *testing.T, home, name, remote string) { } } -// setStatus publishes by atomic rename because the fake herdr cats this file from -// another process: a truncating write would let it read a phantom empty status, -// which classifies as a transition to unknown and swallows the real one. +// Publishes by atomic rename because the fake herdr cats this file from another process: a +// truncating write would let it read a phantom empty status, which classifies as a transition to +// unknown and swallows the real one. func setStatus(t *testing.T, statusFile, status string) { t.Helper() tmp := statusFile + ".tmp" @@ -184,15 +174,9 @@ func setupWatcherHome(t *testing.T, taskOpts state.Task) (home string) { return home } -// TestTickClassifiesNotBusyAsIdleUnreportedRegardlessOfHerdrSpelling proves the fix -// for #30/#32/#33's working->idle bug against the herdr spelling hand's headless -// polling actually observes: herdr renders a working/blocked->idle transition as -// "done", not "idle", unless a live OS-focused herdr client has that pane's tab -// active at the instant of the transition (see herdr.Status's doc comment) - a -// condition hand's pure-polling model never satisfies. An earlier version of this -// test drove the fake herdr's status file to "done" and expected an unconditional -// "done" event; that was itself the bug the fix corrects, so it's now expected to -// behave exactly like an unexplained idle transition: idle-unreported. +// Proves the working->idle fix (atqamz/secondhand#30, atqamz/secondhand#32, atqamz/secondhand#33) +// against the spelling hand's headless polling observes: herdr renders working/blocked->idle as "done", +// not "idle", unless a live OS-focused client has that tab active then (see herdr.Status's doc). func TestTickClassifiesNotBusyAsIdleUnreportedRegardlessOfHerdrSpelling(t *testing.T) { statusFile := filepath.Join(t.TempDir(), "status") setStatus(t, statusFile, "working") @@ -211,6 +195,9 @@ func TestTickClassifiesNotBusyAsIdleUnreportedRegardlessOfHerdrSpelling(t *testi t.Fatalf("first tick printed output for newly seen task: %q", buf.String()) } + // Driving the status file to "done" is what an earlier version of this test did while expecting an + // unconditional "done" event - itself the bug the fix corrects, since hand's pure-polling model + // never satisfies that focus condition, so this is exactly an unexplained idle transition. setStatus(t, statusFile, "done") buf.Reset() tick(ctx, cfg, client, states, &buf, &errBuf) @@ -247,10 +234,9 @@ func TestTickClassifiesNotBusyAsIdleUnreportedRegardlessOfHerdrSpelling(t *testi } } -// TestTickRecordsVerifiedDoneOnlyOnceReportedDoneIsVerified is the only -// remaining source of a verified-done record: a worker's own "done" report, -// cross-checked against a task the caller has already recorded as merged. -// herdr's agent_status never drives this by itself - see the test above. +// The only remaining source of a verified-done record: a worker's own "done" report, cross-checked +// against a task the caller has already recorded as merged. herdr's agent_status never drives this by +// itself - see the test above. func TestTickRecordsVerifiedDoneOnlyOnceReportedDoneIsVerified(t *testing.T) { statusFile := filepath.Join(t.TempDir(), "status") setStatus(t, statusFile, "working") @@ -486,9 +472,8 @@ func TestTickDoesNotOverwriteAlreadyRecordedPR(t *testing.T) { } } -// TestTickRefusesToAutoRecordAForeignRepoPR is the guard against the worst -// outcome of trusting a worker's text: a PR URL from an unrelated repo becoming -// the task's PR, which `hand merge` would then merge for real. +// The guard against the worst outcome of trusting a worker's text: a PR URL from an unrelated repo +// becoming the task's PR, which `hand merge` would then merge for real. func TestTickRefusesToAutoRecordAForeignRepoPR(t *testing.T) { statusFile := filepath.Join(t.TempDir(), "status") setStatus(t, statusFile, "working") @@ -534,11 +519,9 @@ func TestTickRefusesToAutoRecordAForeignRepoPR(t *testing.T) { } } -// TestTickKeepsAWorkerQuestionWhenItsPRURLIsRefused pins the slot ownership: one -// report line can both ask the supervisor something and carry a URL that fails to -// record, and the task's own last-reported state/note is keyed by task ID, so a -// second writer touching it for the refused PR would erase the question from the -// one surface a supervisor is told to read first. +// Pins the slot ownership: one report line can both ask the supervisor something and carry a URL that +// fails to record, and the last-reported state/note is keyed by task ID, so a second writer touching +// it for the refused PR would erase the question from the surface a supervisor reads first. func TestTickKeepsAWorkerQuestionWhenItsPRURLIsRefused(t *testing.T) { statusFile := filepath.Join(t.TempDir(), "status") setStatus(t, statusFile, "working") @@ -575,10 +558,9 @@ func TestTickKeepsAWorkerQuestionWhenItsPRURLIsRefused(t *testing.T) { } } -// TestTickSurfacesAContendedAutoRecordInsteadOfWaiting covers the poll loop's -// no-blocking-lock rule at the auto-record site: another command holding the task -// lock across network work must not stall the watcher, so the tick reports the -// contention through the ordinary refusal path and moves on. +// Covers the poll loop's no-blocking-lock rule at the auto-record site: another command holding the +// task lock across network work must not stall the watcher, so the tick reports the contention +// through the ordinary refusal path and moves on. func TestTickSurfacesAContendedAutoRecordInsteadOfWaiting(t *testing.T) { statusFile := filepath.Join(t.TempDir(), "status") setStatus(t, statusFile, "working") @@ -637,16 +619,9 @@ func TestTickSurfacesAContendedAutoRecordInsteadOfWaiting(t *testing.T) { } } -// TestTickStaysSilentWhenTheLockHolderRecordedTheSamePR covers the race the -// non-blocking task lock introduced: `hand pr` holds that lock across its own gh -// round-trip while recording the very URL the watcher just read off the report. -// Announcing anything there is a false alarm naming a no-op remedy. -// -// The holder's write has to land after tick's own state.List snapshot, or the -// pre-existing "task already has a PR" guard absorbs the URL and the contention -// path under test is never reached - which is what made an earlier version of -// this test vacuous. Hence the gh double writes it: ValidatePR shells out to gh -// on the way to the lock, so the hook fires inside exactly that window. +// Covers the race the non-blocking task lock introduced: `hand pr` holds that lock across its own gh +// round-trip while recording the very URL the watcher just read off the report. Announcing anything +// there is a false alarm naming a no-op remedy. func TestTickStaysSilentWhenTheLockHolderRecordedTheSamePR(t *testing.T) { statusFile := filepath.Join(t.TempDir(), "status") setStatus(t, statusFile, "working") @@ -656,7 +631,12 @@ func TestTickStaysSilentWhenTheLockHolderRecordedTheSamePR(t *testing.T) { registerProject(t, home, "nsr", "https://github.com/atqamz/secondhand.git") url := "https://github.com/atqamz/secondhand/pull/7" + // The holder's write has to land after tick's own state.List snapshot, or the pre-existing "task + // already has a PR" guard absorbs the URL and the contention path under test is never reached - + // which is what made an earlier version of this test vacuous. snapshot := taskSnapshotWithPR(t, home, "task-1", url) + // Hence the gh double writes it: ValidatePR shells out to gh on the way to the lock, so the hook + // fires inside exactly that window. writeFakeGhWithHook(t, "OPEN", fmt.Sprintf("cp %q %q", snapshot, store.Path(home))) cfg := Config{Home: home, PollInterval: time.Hour, StaleThreshold: time.Hour} @@ -763,11 +743,9 @@ func TestTickReportsAnUnreadableTaskWhenTheLockIsContended(t *testing.T) { } } -// TestTickAnnouncesPRMergedBeforePersistingIt pins the ordering the durable -// marker depends on. Reading the task state at the instant the line hits stdout -// is exactly what a restarted watcher would find had the process died there: the -// marker must still be unset, so the announcement is re-derivable. Persisting -// first would trade a tolerable duplicate for a permanently lost event. +// Pins the ordering the durable marker depends on. Reading task state at the instant the line hits +// stdout is what a restarted watcher would find had the process died there: the marker must still be +// unset, so the announcement is re-derivable; persisting first trades a duplicate for a lost event. func TestTickAnnouncesPRMergedBeforePersistingIt(t *testing.T) { statusFile := filepath.Join(t.TempDir(), "status") setStatus(t, statusFile, "working") @@ -801,8 +779,8 @@ func TestTickAnnouncesPRMergedBeforePersistingIt(t *testing.T) { } } -// stateAtWriteWriter records what a task's durable state held at the moment each -// event line was written, keyed by the line. +// Records what a task's durable state held at the moment each event line was written, keyed by the +// line. type stateAtWriteWriter struct { t *testing.T home string @@ -820,8 +798,7 @@ func (w *stateAtWriteWriter) Write(p []byte) (int, error) { return w.buf.Write(p) } -// TestTickDoesNotReannounceAPollObservedMergeAfterRestart covers the other half -// of the durable marker: a merge only this watcher's gh poll ever saw, and the +// The other half of the durable marker: a merge only this watcher's gh poll ever saw, and the // verified done that followed it, must not be re-emitted by the next process. func TestTickDoesNotReannounceAPollObservedMergeAfterRestart(t *testing.T) { statusFile := filepath.Join(t.TempDir(), "status") @@ -878,9 +855,8 @@ func TestTickReportsAnUnreadableReport(t *testing.T) { } } -// TestTickResumesReportTailAfterRestart proves the offset survives the process: -// a fresh states map (a restarted hand watch) must not replay lines the previous -// run already surfaced, and must not forget the report explaining a quiet pane. +// Proves the offset survives the process: a fresh states map (a restarted hand watch) must not replay +// lines the previous run already surfaced, and must not forget the report explaining a quiet pane. func TestTickResumesReportTailAfterRestart(t *testing.T) { statusFile := filepath.Join(t.TempDir(), "status") setStatus(t, statusFile, "working") @@ -927,13 +903,9 @@ func TestTickResumesReportTailAfterRestart(t *testing.T) { } } -// A worker's `done:` rewrite that happens to land on the byte count of the -// `working:` line before it was skipped outright: the offset still sat just past -// the file's final newline with nothing after it, so nothing was announced, -// LastReportState stayed `working`, and ClassifyDeferredDone - gated on it - never -// ran for a worker that had finished (atqamz/secondhand#149). The restart in the -// middle is the point of doing this at tick level: what makes the rewrite -// detectable has to survive the process, exactly as the offset does. +// A `done:` rewrite landing on the byte count of the `working:` line before it was skipped outright +// (atqamz/secondhand#149): the offset still sat just past the final newline with nothing after it, so +// nothing was announced, LastReportState stayed `working`, and ClassifyDeferredDone - gated on it - never ran. func TestTickAnnouncesADoneRewrittenToTheSameLengthAcrossARestart(t *testing.T) { statusFile := filepath.Join(t.TempDir(), "status") setStatus(t, statusFile, "working") @@ -959,6 +931,8 @@ func TestTickAnnouncesADoneRewrittenToTheSameLengthAcrossARestart(t *testing.T) } tick(ctx, cfg, client, states, &buf, io.Discard) + // Restarting mid-test is the point of doing this at tick level: what makes the rewrite detectable has + // to survive the process, exactly as the offset does. restarted := make(map[string]*TaskState) buf.Reset() tick(ctx, cfg, client, restarted, &buf, io.Discard) @@ -1034,11 +1008,9 @@ func TestTickFiresParkedOnFirstResumedTickWhenTheSilenceAlreadyExceedsTheBound(t } } -// A done worker's report file never grows again, so the silence instant parked -// fired against is frozen: a re-derived latch fires against that same instant on -// every restart, and state/events.log is capped, so the duplicates evict real -// history. The whole point of persisting the latch is this second run staying -// quiet. +// A done worker's report file never grows again, so the silence instant parked fired against is +// frozen: a re-derived latch fires against that same instant on every restart, and state/events.log +// is capped, so the duplicates evict real history. func TestTickDoesNotRefireParkedForADoneTaskAcrossARestart(t *testing.T) { statusFile := filepath.Join(t.TempDir(), "status") setStatus(t, statusFile, "working") @@ -1076,6 +1048,7 @@ func TestTickDoesNotRefireParkedForADoneTaskAcrossARestart(t *testing.T) { } buf.Reset() + // The whole point of persisting the latch is this second run staying quiet. restarted := make(map[string]*TaskState) tick(context.Background(), cfg, client, restarted, &buf, io.Discard) tick(context.Background(), cfg, client, restarted, &buf, io.Discard) @@ -1084,10 +1057,9 @@ func TestTickDoesNotRefireParkedForADoneTaskAcrossARestart(t *testing.T) { } } -// The two facts the floor has to keep apart. An outage restamps status_changed_at -// for a pane the watcher could not reach, which must not move the floor at all; -// a promote restamps pane_started_at, which must move it past the scout's whole -// accumulated silence. Reading either field for both jobs gets one of them wrong. +// The two facts the floor has to keep apart. An outage restamps status_changed_at for a pane the +// watcher could not reach, which must not move the floor at all; a promote restamps pane_started_at, +// which must move it past the scout's whole silence. Reading either field for both jobs breaks one. func TestReportEvidenceTimeFloorsOnThePaneStartNotTheOutageStamp(t *testing.T) { now := time.Now() home := t.TempDir() @@ -1199,12 +1171,9 @@ func TestTickRefusesADurableDwellStampedForADifferentStatus(t *testing.T) { } } -// TestTickAnnouncesAVerifiedDoneAfterARestartThatMissedTheEvidence covers the -// window between the two halves: the worker reports done, hand watch stops, and -// hand merge lands the work by writing merged. On restart the evidence is -// already on disk, so a marker re-derived from current evidence would conclude -// the verified line had gone out and never print it, leaving the task's -// recorded state stuck where the unverified report left it. +// Covers the window between the two halves: the worker reports done, hand watch stops, and hand merge +// lands the work by writing merged. On restart the evidence is already on disk, so a marker re-derived +// from it would conclude the verified line went out and never print it. func TestTickAnnouncesAVerifiedDoneAfterARestartThatMissedTheEvidence(t *testing.T) { statusFile := filepath.Join(t.TempDir(), "status") setStatus(t, statusFile, "working") @@ -1255,6 +1224,7 @@ func TestTickAnnouncesAVerifiedDoneAfterARestartThatMissedTheEvidence(t *testing if err != nil { t.Fatal(err) } + // Without the announcement the recorded state stays stuck where the unverified report left it. if task.LastReportState != state.ReportDone { t.Fatalf("LastReportState = %q, want the task's own recorded state moved to done", task.LastReportState) } @@ -1509,13 +1479,9 @@ func TestForgetPaneScopedCacheGivesTheShipsFirstProbeFailureADwell(t *testing.T) } } -// TestForgetPaneScopedCacheHandlesEveryField is a completeness guard, not a behavior -// test: TestForgetPaneScopedCacheClearsEveryPaneAnchoredLatch above only asserts the -// fields already known to need resetting, and would keep passing if a future field -// repeated the exact defect Status and then Probed both had - present in TaskState, -// absent from forgetPaneScopedCache. This test instead walks every field by -// reflection, so a field neither reset/re-derived nor named in the carried map below -// fails it automatically by staying equal to its deliberately-stale "before" value. +// A completeness guard, not a behavior test: TestForgetPaneScopedCacheClearsEveryPaneAnchoredLatch +// above asserts only the fields already known to need resetting, and would keep passing if a future +// field repeated the defect Status and then Probed both had - in TaskState, absent from the function. func TestForgetPaneScopedCacheHandlesEveryField(t *testing.T) { before := TaskState{ CreatedAt: "created-marker", @@ -1553,15 +1519,9 @@ func TestForgetPaneScopedCacheHandlesEveryField(t *testing.T) { LastReportNote: "ship-report-note", } - // carried names every field the PR body's field-by-field table classifies as - // genuinely pane-independent, so forgetPaneScopedCache is correct to leave it - // untouched: identity (CreatedAt), PR facts (PRMerged), report-file position - // (ReportCursor/PersistedCursor, PersistedPRMerged mirrors the same PR fact), - // and the parked latch (ParkedFiredFor is keyed to the report mtime, not the - // pane, and PersistedParkedFiredFor mirrors that same fact). Anything else - // added to TaskState later needs an entry here with a reason, or - // forgetPaneScopedCache needs to handle it - this test does not care which, - // only that the decision was made on purpose. + // Every field the PR body's field-by-field table classifies as pane-independent, so + // forgetPaneScopedCache is right to leave it alone: identity, PR facts, report-file position, and + // the parked latch, keyed to the report mtime not the pane. Persisted* entries mirror those facts. carried := map[string]bool{ "CreatedAt": true, "PRMerged": true, @@ -1575,6 +1535,9 @@ func TestForgetPaneScopedCacheHandlesEveryField(t *testing.T) { ts := before forgetPaneScopedCache(&ts, promoted, time.Now()) + // Walking every field by reflection: one neither reset/re-derived nor named in the carried map fails + // automatically by staying equal to its deliberately-stale "before" value. Which of the two fixes it + // gets does not matter here, only that the decision was made on purpose. beforeVal, afterVal := reflect.ValueOf(before), reflect.ValueOf(ts) typ := beforeVal.Type() seen := make(map[string]bool, typ.NumField()) @@ -1702,8 +1665,8 @@ func TestSyncTaskStateDropsCachePromoteInvalidatedMidTick(t *testing.T) { } } -// hasEventLine matches want as a whole output line, so "reported-done " and -// "done " - one a substring of the other - can't be confused. +// Matches want as a whole output line, so "reported-done " and "done " - one a substring of +// the other - cannot be confused. func hasEventLine(out, want string) bool { for _, line := range strings.Split(out, "\n") { if line == want { @@ -1713,12 +1676,9 @@ func hasEventLine(out, want string) bool { return false } -// TestTickKeepsAMultiLineAutoRecordFailureOnOneLine pins the one-line-per-event -// invariant against its noisiest real cause: ghutil wraps gh's stderr into the -// error verbatim, and gh emits several lines for auth and network failures. A -// multi-line Event.Text breaks the stdout contract and makes events.log's -// 200-line bound count one event as several. The cause is preserved - only its -// line breaks are not. +// Pins the one-line-per-event invariant against its noisiest real cause: ghutil wraps gh's stderr +// into the error verbatim, and gh emits several lines for auth and network failures. A multi-line +// Event.Text breaks the stdout contract and makes events.log's 200-line bound count one as several. func TestTickKeepsAMultiLineAutoRecordFailureOnOneLine(t *testing.T) { statusFile := filepath.Join(t.TempDir(), "status") setStatus(t, statusFile, "working") @@ -1743,9 +1703,9 @@ func TestTickKeepsAMultiLineAutoRecordFailureOnOneLine(t *testing.T) { buf.Reset() tick(ctx, cfg, client, states, &buf, &errBuf) - // The same report line also emits reported-done, so the invariant under test - // is per event: the failure occupies exactly one line, carrying the whole - // cause, and no fragment of it lands on a line of its own. + // The same report line also emits reported-done, so the invariant under test is per event: the + // failure occupies exactly one line, keeping the whole cause and losing only its line breaks, with + // no fragment of it on a line of its own. printed := strings.Split(strings.TrimRight(buf.String(), "\n"), "\n") var failures []string for _, line := range printed { @@ -1773,10 +1733,9 @@ func TestTickKeepsAMultiLineAutoRecordFailureOnOneLine(t *testing.T) { } } -// writeFakeGhFailingMultiline mirrors the real gh's noisiest failure: auth and -// network errors exit non-zero having written several lines to stderr, which -// ghutil.PRIsMerged wraps into the returned error verbatim. Nothing is written -// to stdout, as with the real tool on this path. +// Mirrors the real gh's noisiest failure: auth and network errors exit non-zero having written +// several lines to stderr, which ghutil.PRIsMerged wraps into the returned error verbatim. Nothing is +// written to stdout, as with the real tool on this path. func writeFakeGhFailingMultiline(t *testing.T) { t.Helper() bin := t.TempDir() @@ -1944,10 +1903,9 @@ func TestHandleEventReportsAFailingNotifyTemplateToErrOut(t *testing.T) { } func TestRunFailsWhenHerdrUnreachable(t *testing.T) { - // exit 1 with empty stdout is the faithful crashed-or-missing-binary shape, - // which call()'s empty-stdout-plus-runErr branch handles (len(trimmed) == 0 - // && runErr != nil). It is a distinct shape from herdr's ordinary failure - // (exit 0 plus an error envelope), and only this one means "unreachable". + // exit 1 with empty stdout is the faithful crashed-or-missing-binary shape, which call()'s + // empty-stdout-plus-runErr branch handles. A distinct shape from herdr's ordinary failure (exit 0 + // plus an error envelope), and only this one means "unreachable". bin := t.TempDir() if err := os.WriteFile(filepath.Join(bin, "herdr"), []byte("#!/bin/sh\nexit 1\n"), 0o755); err != nil { t.Fatal(err) @@ -1990,10 +1948,9 @@ func TestRunExitsCleanlyOnContextCancel(t *testing.T) { } } -// TestRunUntilEventTakesTheStartupStateAsBaseline is the regression test for the -// delivery failure of 2026-07-28: the grep-on-first-line wrapper this mode -// replaces matched a done worker's startup line, took it for a transition, and -// left the two real events that followed unread for three hours. +// The regression test for the delivery failure of 2026-07-28: the grep-on-first-line wrapper this +// mode replaces matched a done worker's startup line, took it for a transition, and left the two real +// events that followed unread for three hours. func TestRunUntilEventTakesTheStartupStateAsBaseline(t *testing.T) { statusFile := filepath.Join(t.TempDir(), "status") setStatus(t, statusFile, "done") @@ -2055,11 +2012,9 @@ func TestRunUntilEventDeliversTheFirstTransitionAndReturns(t *testing.T) { } } -// TestRunUntilEventFiltersWakesToTheRequestedKinds covers #85: a caller that -// only wants to wake on blocked must not be woken by a routine idle-unreported -// transition, but the filtered-out event still has to reach events.log exactly -// like a baseline tick's events already do - the filter gates the wake, not the -// record. +// Covers atqamz/secondhand#85: a caller that only wants to wake on blocked must not be woken by a +// routine idle-unreported transition, but the filtered-out event still has to reach events.log exactly +// like a baseline tick's events already do - the filter gates the wake, not the record. func TestRunUntilEventFiltersWakesToTheRequestedKinds(t *testing.T) { statusFile := filepath.Join(t.TempDir(), "status") setStatus(t, statusFile, "working") @@ -2288,11 +2243,9 @@ func TestRunUntilEventFailsToArmWhenATaskCannotBeProbed(t *testing.T) { } } -// TestTickResumesTheLastStateAfterATrailingMalformedLine holds resume to what the -// live path already does: a free-text line appended after a real report explains -// nothing, so it must not erase the report it follows. Reading it back as "never -// reported" turns the next quiet pane into idle-unreported, replacing the -// worker's own explanation with a bare unexplained stop. +// Holds resume to what the live path already does: a free-text line appended after a real report +// explains nothing, so it must not erase the report it follows. Reading it back as "never reported" +// turns the next quiet pane into idle-unreported, replacing the explanation with a bare stop. func TestTickResumesTheLastStateAfterATrailingMalformedLine(t *testing.T) { statusFile := filepath.Join(t.TempDir(), "status") setStatus(t, statusFile, "working") @@ -2336,11 +2289,9 @@ func TestTickResumesTheLastStateAfterATrailingMalformedLine(t *testing.T) { } } -// TestTickReseedsARespawnedTaskID keys tracking on identity rather than on ID. A -// teardown and respawn between two ticks is a different task, and inheriting the -// previous run's TaskState suppresses the new one's verified done for good: -// syncTaskState writes that inherited done_verified onto the fresh JSON. Same -// hazard as a surviving report channel, one layer in. +// Keys tracking on identity rather than on ID. A teardown and respawn between two ticks is a +// different task, and inheriting the previous run's TaskState suppresses the new one's verified done +// for good: syncTaskState writes that inherited done_verified onto the fresh JSON. func TestTickReseedsARespawnedTaskID(t *testing.T) { statusFile := filepath.Join(t.TempDir(), "status") setStatus(t, statusFile, "working") @@ -2374,6 +2325,8 @@ func TestTickReseedsARespawnedTaskID(t *testing.T) { if err := state.Delete(home, "task-1"); err != nil { t.Fatal(err) } + // Same hazard as a surviving report channel, one layer in, so the scout's report.md goes with the + // state row. if err := os.Remove(reportMD); err != nil { t.Fatal(err) } @@ -2411,15 +2364,9 @@ func TestTickReseedsARespawnedTaskID(t *testing.T) { } } -// TestTickSetsTheStateColumnOnAReportedStop covers the well-behaved worker: it -// says why it stopped, herdr's not-busy transition is then absorbed on purpose, -// and the task's recorded state would otherwise keep reading "working" - the -// very bug the report channel exists to remove, with the supervisor reading -// that state first. The last step is the way back, the steer-and-continue -// loop: nothing else in the codebase writes "working" to that state, so -// without report-working the task latches on the stop-state and a steered -// worker shows as awaiting a decision forever - the same two-views-disagree -// defect, inverted. +// Covers the well-behaved worker: it says why it stopped, herdr's not-busy transition is then absorbed +// on purpose, and the recorded state would otherwise keep reading "working" - the very bug the report +// channel exists to remove, with the supervisor reading that state first. func TestTickSetsTheStateColumnOnAReportedStop(t *testing.T) { statusFile := filepath.Join(t.TempDir(), "status") setStatus(t, statusFile, "working") @@ -2439,6 +2386,9 @@ func TestTickSetsTheStateColumnOnAReportedStop(t *testing.T) { {"blocked: needs an API key\n", state.ReportBlocked}, {"needs-decision: which base branch?\n", state.ReportNeedsDecision}, {"paused: sleeping on it\n", state.ReportPaused}, + // The way back, the steer-and-continue loop: nothing else in the codebase writes "working" to that + // state, so without report-working the task latches on the stop-state and a steered worker shows + // as awaiting a decision forever - the same two-views-disagree defect, inverted. {"working: main, carrying on\n", state.ReportWorking}, } { report += tc.line @@ -2457,16 +2407,9 @@ func TestTickSetsTheStateColumnOnAReportedStop(t *testing.T) { } } -// A pane hand cannot probe says nothing about a question the worker already asked, -// and clearing it would be unrecoverable: the report line is already past -// report_offset, and the recovery tick emits no event because the tracked status -// never changed. ClassifyStatus fires failed on any probe error, so a herdr daemon -// restart would otherwise wipe every tracked task's last-reported state in one -// tick - fleet-wide loss out of a transient blip. -// TestTickStaysSilentOnABlinkAtFirstSighting covers #81's hard part: a task -// whose very first sighting finds its pane unreachable must not be dropped -// (the old !tracked branch's bare continue), but a probe failure that clears -// before the dwell matures - a blink - must produce nothing at all. +// Covers atqamz/secondhand#81's hard part: a task whose very first sighting finds its pane unreachable +// must not be dropped (the old !tracked branch's bare continue), but a probe failure that clears before +// the dwell matures - a blink - must produce nothing at all. func TestTickStaysSilentOnABlinkAtFirstSighting(t *testing.T) { statusFile := filepath.Join(t.TempDir(), "status") setStatus(t, statusFile, paneGoneStatus) @@ -2495,9 +2438,8 @@ func TestTickStaysSilentOnABlinkAtFirstSighting(t *testing.T) { } } -// TestTickAnnouncesATaskUnreachableAtFirstSightingOnceTheDwellMatures covers the -// other half: a pane that stays dark must produce exactly one failed event, -// not one per tick, and not never. +// The other half: a pane that stays dark must produce exactly one failed event, not one per tick, and +// not never. func TestTickAnnouncesATaskUnreachableAtFirstSightingOnceTheDwellMatures(t *testing.T) { statusFile := filepath.Join(t.TempDir(), "status") setStatus(t, statusFile, paneGoneStatus) @@ -2531,10 +2473,9 @@ func TestTickAnnouncesATaskUnreachableAtFirstSightingOnceTheDwellMatures(t *test } } -// TestTickResumesAnUnreachableDwellAcrossARestart ties the outage clock to -// durable evidence the same way stale and parked already do: a restart mid- -// outage must not reset the dwell to zero, or a long-dark task would silently -// buy itself a fresh grace period every time the watcher restarts. +// Ties the outage clock to durable evidence the same way stale and parked already do: a restart +// mid-outage must not reset the dwell to zero, or a long-dark task would silently buy itself a fresh +// grace period every time the watcher restarts. func TestTickResumesAnUnreachableDwellAcrossARestart(t *testing.T) { statusFile := filepath.Join(t.TempDir(), "status") setStatus(t, statusFile, paneGoneStatus) @@ -2564,6 +2505,9 @@ func TestTickResumesAnUnreachableDwellAcrossARestart(t *testing.T) { } } +// A pane hand cannot probe says nothing about a question the worker already asked, and clearing it +// would be unrecoverable: the report line is already past report_offset, and the recovery tick emits +// no event because the tracked status never changed. func TestTickKeepsAPendingQuestionWhenThePaneProbeFails(t *testing.T) { statusFile := filepath.Join(t.TempDir(), "status") setStatus(t, statusFile, "working") @@ -2582,6 +2526,8 @@ func TestTickKeepsAPendingQuestionWhenThePaneProbeFails(t *testing.T) { } tick(ctx, cfg, client, states, &bytes.Buffer{}, io.Discard) + // ClassifyStatus fires failed on any probe error, so a herdr daemon restart would otherwise wipe + // every tracked task's last-reported state in one tick - fleet-wide loss out of a transient blip. setStatus(t, statusFile, paneGoneStatus) var buf bytes.Buffer tick(ctx, cfg, client, states, &buf, io.Discard) diff --git a/internal/worktree/worktree.go b/internal/worktree/worktree.go index 57bb506..0711ed8 100644 --- a/internal/worktree/worktree.go +++ b/internal/worktree/worktree.go @@ -18,10 +18,8 @@ type Lease struct { ID string } -// Get acquires a worktree from the project clone's treehouse pool. -// clonePath must be the project clone directory (treehouse resolves the pool from cwd). -// treehouse writes banners to stderr ahead of the JSON, so the payload must be read -// from stdout alone; CombinedOutput here corrupts every parse (issue #21). +// Get acquires a worktree from the project clone's treehouse pool. clonePath must be the project +// clone directory, because treehouse resolves the pool from its own working directory. func Get(clonePath, leaseHolder string) (Lease, error) { args := []string{"get", "--lease", "--json"} if leaseHolder != "" { @@ -31,6 +29,8 @@ func Get(clonePath, leaseHolder string) (Lease, error) { cmd.Dir = clonePath var stderr bytes.Buffer cmd.Stderr = &stderr + // Banners land on stderr ahead of the JSON, so the payload has to be read from stdout alone - + // CombinedOutput here corrupts every parse (atqamz/secondhand#21). out, err := cmd.Output() if err != nil { return Lease{}, fmt.Errorf("treehouse get failed: %w: %s", err, strings.TrimSpace(stderr.String())) @@ -67,32 +67,31 @@ func Return(worktreePath string, force bool) error { return nil } -// CheckCollision cross-checks a freshly acquired lease against every other task's -// recorded one, returning the ID of the conflicting task or "" for no collision. -// -// Keyed on the lease identity rather than the worktree path, because a pool slot -// path is recycled across leases while an identity never is: a row a failed -// teardown left behind still names a path treehouse has already freed, and path -// equality refused the next spawn over that instead of over a real holder. -// Path comparison stays the fallback whenever either side has no identity - rows -// written before the lease_id column existed, and any treehouse older than v2.1.0. -// Every task row is compared, done and failed ones included, because a task keeps -// its lease until teardown returns it. SPECS.md's "Collision guard" owns the rest. +// CheckCollision cross-checks a freshly acquired lease against every other task's recorded one, +// returning the ID of the conflicting task or "" for no collision. SPECS.md's "Collision guard" +// owns the rest. func CheckCollision(homeDir string, lease Lease, excludeID string) (string, error) { tasks, err := state.List(homeDir) if err != nil { return "", err } + // Done and failed rows are compared too, because a task holds its lease until teardown + // returns it. for _, t := range tasks { if t.ID == excludeID { continue } if lease.ID != "" && t.LeaseID != "" { + // Identity rather than path: a pool slot path is recycled across leases while an + // identity never is, so a row a failed teardown left behind still names a path + // treehouse has freed, and path equality refused the next spawn over that. if t.LeaseID == lease.ID { return t.ID, nil } continue } + // Fallback whenever either side has no identity, which is any row written before the + // lease_id column existed. if t.Worktree == lease.Path { return t.ID, nil } diff --git a/internal/worktree/worktree_test.go b/internal/worktree/worktree_test.go index 514249c..9633596 100644 --- a/internal/worktree/worktree_test.go +++ b/internal/worktree/worktree_test.go @@ -162,10 +162,9 @@ func TestCheckCollisionDetectsAConflictOnLeaseIdentity(t *testing.T) { } } -// The whole point of keying on identity: teardown returns the worktree before -// state.Delete, so a failed Delete leaves a row still naming path P while -// treehouse has already freed P and handed it to the next task under a lease of -// its own. That is not a collision, and refusing the spawn over it was the bug. +// Teardown returns the worktree before state.Delete, so a failed Delete leaves a row still naming +// path P while treehouse has freed P and handed it to the next task under a lease of its own. That +// is not a collision, and refusing the spawn over it was the bug. func TestCheckCollisionAllowsAReusedPathUnderAFreshLease(t *testing.T) { home := t.TempDir() if err := state.Write(home, state.Task{ID: "stale-task", Worktree: "/tmp/wt-shared", LeaseID: "lease-1"}); err != nil { diff --git a/tests/contract/gh_test.go b/tests/contract/gh_test.go index 684d820..fecc7cf 100644 --- a/tests/contract/gh_test.go +++ b/tests/contract/gh_test.go @@ -36,7 +36,7 @@ func TestGHPRListAnswersAnEmptyArrayForABranchWithNoPR(t *testing.T) { } } -// The slug case #147 fixed: gh serves a repo under any casing and answers with +// The slug case atqamz/secondhand#147 fixed: gh serves a repo under any casing and answers with // the canonical one, so a comparison against a git remote has to fold case. func TestGHServesARepoUnderAnyCasingAndAnswersCanonically(t *testing.T) { requireGH(t) diff --git a/tests/e2e/brief_tier_test.go b/tests/e2e/brief_tier_test.go index af16b6b..96f608d 100644 --- a/tests/e2e/brief_tier_test.go +++ b/tests/e2e/brief_tier_test.go @@ -13,8 +13,7 @@ import ( "github.com/atqamz/secondhand/internal/state" ) -// writeBriefWith writes a brief whose body the test controls, so a declaration -// block can be put in front of real prose. +// Writes a brief whose body the test controls, so a declaration block can be put in front of real prose. func writeBriefWith(t *testing.T, home, id, body string) { t.Helper() if err := os.MkdirAll(filepath.Join(home, "data", id), 0o755); err != nil { @@ -210,9 +209,8 @@ func TestPromoteHonorsBriefDeclaredTier(t *testing.T) { } } -// TestSpawnWarnsOnEffortIncapableHarness covers the deliberate middle path for -// a declared effort no harness flag can carry: the spawn proceeds, the model -// still applies, and the dropped effort is said out loud on stderr. +// Covers the deliberate middle path for a declared effort no harness flag can carry: the spawn proceeds, +// the model still applies, and the dropped effort is said out loud on stderr. func TestSpawnWarnsOnEffortIncapableHarness(t *testing.T) { home := newHome(t) registerProject(t, home, "demo", "local-only") diff --git a/tests/e2e/collision_test.go b/tests/e2e/collision_test.go index e37fa49..0b9bdcd 100644 --- a/tests/e2e/collision_test.go +++ b/tests/e2e/collision_test.go @@ -11,9 +11,8 @@ import ( "github.com/atqamz/secondhand/internal/state" ) -// setupCollisionHome builds the two-task, one-pool-slot fixture both tests below -// spawn into, and returns the fake bin directory so each can install the -// treehouse fake whose lease behavior it is about. +// Builds the two-task, one-pool-slot fixture both tests below spawn into, and returns the fake bin +// directory so each can install the treehouse fake whose lease behavior it is about. func setupCollisionHome(t *testing.T) (string, string) { t.Helper() home := newHome(t) @@ -31,20 +30,15 @@ func setupCollisionHome(t *testing.T) (string, string) { return home, dir } -// TestSpawnDetectsWorktreeCollision proves worktree.CheckCollision is actually -// wired into the built spawn command, not just unit-tested in isolation: a -// second spawn onto a slot a surviving task row still names must be refused -// before it ever reaches herdr, and must leave no trace of task-2. -// -// The fake here is a treehouse older than v2.1.0, reporting no lease identity at -// all, which is what drives the guard down its path-comparison fallback - the -// same branch a task row written before the lease_id column existed takes. With -// nothing to key on but the path, the fallback cannot tell task-1's row from a -// live holder, so it refuses; that conservatism is the whole point of keeping it. +// Proves worktree.CheckCollision is actually wired into the built spawn command, not just unit-tested in +// isolation: a second spawn onto a slot a surviving task row still names must be refused before it ever +// reaches herdr, and must leave no trace of task-2. func TestSpawnDetectsWorktreeCollision(t *testing.T) { home, dir := setupCollisionHome(t) sharedWorktree := filepath.Join(home, "wt-shared") + // With nothing to key on but the path, the fallback this fake drives cannot tell task-1's row from a live + // holder, so it refuses; that conservatism is the whole point of keeping it. writeFakeTreehouseWithoutLeaseIdentity(t, dir, sharedWorktree) first := runHand(t, home, "spawn", "task-1", "demo") @@ -71,12 +65,9 @@ func TestSpawnDetectsWorktreeCollision(t *testing.T) { } } -// The other branch, end to end: a real treehouse recycles a returned pool slot's -// path under a brand-new lease identity, and a task row still naming that path - -// left behind by a teardown whose state.Delete failed - must not abort the spawn -// that legitimately acquired it. The slot is genuinely back in the pool before -// task-2 asks for it, because that is the only way the real backend hands one -// path out twice. +// The other branch, end to end: a real treehouse recycles a returned pool slot's path under a brand-new +// lease identity, and a task row still naming that path - left behind by a teardown whose state.Delete +// failed - must not abort the spawn that legitimately acquired it. func TestSpawnAllowsARecycledWorktreePathUnderAFreshLease(t *testing.T) { home, dir := setupCollisionHome(t) sharedWorktree := filepath.Join(home, "wt-shared") @@ -86,6 +77,8 @@ func TestSpawnAllowsARecycledWorktreePathUnderAFreshLease(t *testing.T) { if first.code != 0 { t.Fatalf("spawn task-1: exit %d, stderr %q", first.code, first.stderr) } + // Genuinely back in the pool before task-2 asks for it, because that is the only way the real backend + // hands one path out twice. returnFakeWorktree(t, sharedWorktree) second := runHand(t, home, "spawn", "task-2", "demo") diff --git a/tests/e2e/e2e_test.go b/tests/e2e/e2e_test.go index 0ff7c6d..560be92 100644 --- a/tests/e2e/e2e_test.go +++ b/tests/e2e/e2e_test.go @@ -25,16 +25,14 @@ import ( var handBin string -// backendsThisSuiteFakes are the tools hand shells out to that this suite -// never runs for real. None of them may resolve on the hermetic PATH: a real -// one would silently answer in place of a test's fake, so the affected test -// would pass against reality instead of failing. +// The tools hand shells out to that this suite never runs for real. None of them may resolve on the +// hermetic PATH: a real one would silently answer in place of a test's fake, so the affected test would +// pass against reality instead of failing. var backendsThisSuiteFakes = []string{"herdr", "treehouse", "gh", "no-mistakes"} -// assertNoAmbientBackends checks that invariant once, after TestMain has -// narrowed PATH to the fixture PATH and before any test runs, so a change that -// widens realBinsOnPath (or hands a whole directory to buildHermeticPath) fails -// the run loudly instead of quietly re-admitting a real backend. +// Checks that invariant once, after TestMain has narrowed PATH to the fixture PATH and before any test +// runs, so a change that widens realBinsOnPath (or hands a whole directory to buildHermeticPath) fails the +// run loudly instead of quietly re-admitting a real backend. func assertNoAmbientBackends() error { for _, name := range backendsThisSuiteFakes { if path, err := exec.LookPath(name); err == nil { @@ -45,13 +43,9 @@ func assertNoAmbientBackends() error { return nil } -// TestMain builds the hand binary once for the whole package, then replaces -// PATH with a hermetic one (see fakes_test.go's buildHermeticPath) so neither -// the tests nor the hand processes they drive can reach a real herdr, -// treehouse or gh that happens to be installed. go test's result cache is -// keyed on this package's own inputs, not on this nested go build, so changing -// production code alone will not invalidate a cached e2e run - pass -count=1 -// when checking red/green behavior after a production-code-only edit. +// Builds the hand binary once for the whole package, then replaces PATH with a hermetic one (see +// fakes_test.go's buildHermeticPath) so neither the tests nor the hand processes they drive can reach a +// real herdr, treehouse or gh that happens to be installed. func TestMain(m *testing.M) { dir, err := os.MkdirTemp("", "hand-e2e-") if err != nil { @@ -59,6 +53,9 @@ func TestMain(m *testing.M) { os.Exit(1) } handBin = filepath.Join(dir, "hand") + // go test's result cache is keyed on this package's own inputs, not on this nested go build, so changing + // production code alone will not invalidate a cached e2e run - pass -count=1 when checking red/green + // behavior after a production-code-only edit. build := exec.Command("go", "build", "-o", handBin, ".") build.Dir = filepath.Join("..", "..") if out, err := build.CombinedOutput(); err != nil { @@ -79,10 +76,9 @@ func TestMain(m *testing.M) { fmt.Fprintln(os.Stderr, err) os.Exit(1) } - // Every hand process this suite drives inherits this environment and - // resolves HAND_HOME ahead of its working directory, so a developer who - // exported one would otherwise have the suite spawn, merge and tear down - // against their real fleet. + // Every hand process this suite drives inherits this environment and resolves HAND_HOME ahead of its + // working directory, so a developer who exported one would otherwise have the suite spawn, merge and tear + // down against their real fleet. if err := os.Unsetenv("HAND_HOME"); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) @@ -104,8 +100,8 @@ func runHand(t *testing.T, home string, args ...string) invocation { return runHandEnv(t, home, nil, args...) } -// runHandEnv is runHand for the cases that need one extra environment entry, -// appended to the suite's own (already HAND_HOME-free) environment. +// runHand for the cases that need one extra environment entry, appended to the suite's own (already +// HAND_HOME-free) environment. func runHandEnv(t *testing.T, home string, extraEnv []string, args ...string) invocation { t.Helper() cmd := exec.Command(handBin, args...) @@ -130,8 +126,7 @@ func runHandEnv(t *testing.T, home string, extraEnv []string, args ...string) in return invocation{code: code, stdout: stdout.String(), stderr: stderr.String()} } -// syncBuffer lets a test goroutine poll a background hand process's output -// while the process is still writing to it. +// Lets a test goroutine poll a background hand process's output while the process is still writing to it. type syncBuffer struct { mu sync.Mutex buf bytes.Buffer @@ -149,10 +144,9 @@ func (s *syncBuffer) String() string { return s.buf.String() } -// backgroundHand drives a long-running hand command (only `watch` today) -// so a test can observe its streaming output and stop it with the same -// SIGTERM signal.NotifyContext listens for in cmd/watch.go, rather than -// waiting for it to exit on its own. +// Drives a long-running hand command (only `watch` today) so a test can observe its streaming output and +// stop it with the same SIGTERM signal.NotifyContext listens for in cmd/watch.go, rather than waiting for +// it to exit on its own. type backgroundHand struct { cmd *exec.Cmd args []string @@ -195,9 +189,8 @@ func (b *backgroundHand) waitForStdout(t *testing.T, substr string, timeout time t.Fatalf("timed out waiting for %q on stdout; stdout=%q stderr=%q", substr, b.stdout.String(), b.stderr.String()) } -// stop sends SIGTERM (the signal cmd/watch.go's signal.NotifyContext listens -// for) and waits for a clean exit, failing the test if the process doesn't -// exit within timeout. +// Sends SIGTERM (the signal cmd/watch.go's signal.NotifyContext listens for) and waits for a clean exit, +// failing the test if the process doesn't exit within timeout. func (b *backgroundHand) stop(t *testing.T, timeout time.Duration) invocation { t.Helper() if err := b.cmd.Process.Signal(syscall.SIGTERM); err != nil { @@ -206,9 +199,8 @@ func (b *backgroundHand) stop(t *testing.T, timeout time.Duration) invocation { return b.waitForExit(t, timeout, "SIGTERM") } -// waitForExit reaps a process expected to exit on its own, unlike stop: that exit -// is the whole delivery mechanism of `hand watch --until-event`, so it has to be -// observed rather than caused by a signal. +// Reaps a process expected to exit on its own, unlike stop: that exit is the whole delivery mechanism of +// `hand watch --until-event`, so it has to be observed rather than caused by a signal. func (b *backgroundHand) waitForExit(t *testing.T, timeout time.Duration, because string) invocation { t.Helper() b.reaping = true @@ -235,9 +227,8 @@ func (b *backgroundHand) waitForExit(t *testing.T, timeout time.Duration, becaus } } -// waitForInvocation blocks until a fake binary's invocation log contains -// substr, giving a test a positive signal that a background process has -// actually reached a given call instead of guessing with a sleep. +// Blocks until a fake binary's invocation log contains substr, giving a test a positive signal that a +// background process has actually reached a given call instead of guessing with a sleep. func waitForInvocation(t *testing.T, logPath, substr string, timeout time.Duration) { t.Helper() waitForInvocations(t, logPath, substr, 1, timeout) @@ -590,16 +581,13 @@ func TestExitCodeOneOnGeneralError(t *testing.T) { } } -// gitConfigIsolated marks the current test as already pointed at a scratch git -// config, so the helpers below can each demand isolation without clobbering an -// earlier setup (redirectGitRemote's insteadOf rules in particular). +// Marks the current test as already pointed at a scratch git config, so the helpers below can each demand +// isolation without clobbering an earlier setup (redirectGitRemote's insteadOf rules in particular). const gitConfigIsolated = "HAND_E2E_GIT_CONFIG_ISOLATED" -// isolateGitConfig points every git invocation in this test - the test's own -// and the ones hand shells out to - at a scratch config file, so the -// developer's real ~/.gitconfig (commit.gpgsign above all, which would drag -// gpg-agent into every commit these tests make) can never reach them. It -// returns the config path so callers can add their own rules to it. +// Points every git invocation in this test - the test's own and the ones hand shells out to - at a scratch +// config file, so the developer's real ~/.gitconfig (commit.gpgsign above all, which would drag gpg-agent +// into every commit these tests make) can never reach them. func isolateGitConfig(t *testing.T) string { t.Helper() if os.Getenv(gitConfigIsolated) == "1" { @@ -614,6 +602,7 @@ func isolateGitConfig(t *testing.T) string { t.Setenv("GIT_CONFIG_GLOBAL", cfg) t.Setenv("GIT_CONFIG_NOSYSTEM", "1") t.Setenv(gitConfigIsolated, "1") + // Returned rather than kept private so a caller can add its own rules to the same file. return cfg } diff --git a/tests/e2e/fakes_test.go b/tests/e2e/fakes_test.go index 5109336..74966c0 100644 --- a/tests/e2e/fakes_test.go +++ b/tests/e2e/fakes_test.go @@ -13,21 +13,12 @@ import ( "github.com/atqamz/secondhand/internal/faketool" ) -// realBinsOnPath are the only real executables this suite needs to resolve: -// git, which both hand and the test helpers shell out to for real; sh, which -// internal/notify execs to run a notify template (from both cmd/notify.go and -// the watcher's in-process hook); and cat, which the fake herdr scripts below -// use to read a pane's status file. Everything hand execs that is not listed -// here is faked per test (backendsThisSuiteFakes, e2e_test.go), so leaving -// those unreachable turns a missing fake into a loud failure instead of a call -// against the developer's real tools. +// Everything hand execs beyond these three is faked per test and left unreachable, so a missing fake fails +// loudly instead of reaching the developer's real tools (e2e_test.go). The three: git, which hand and the +// helpers really shell out to; sh, for internal/notify's template; cat, for the fake herdr pane status. var realBinsOnPath = []string{"git", "sh", "cat"} -// hermeticPath is the PATH every test runs under, built once by TestMain from -// the inherited PATH. Each needed binary is symlinked in individually rather -// than having its own directory prepended: on a real machine git commonly -// lives in the same directory as real herdr and treehouse, so exposing that -// directory would hand the suite straight back the tools it fakes. +// The PATH every test runs under, built once by TestMain from the inherited PATH. var hermeticPath string func buildHermeticPath(dir string) (string, error) { @@ -39,6 +30,9 @@ func buildHermeticPath(dir string) (string, error) { if err != nil { return "", fmt.Errorf("resolve %s, which this suite runs for real: %w", name, err) } + // Each binary is symlinked in individually rather than given its own directory on PATH: on a real + // machine git commonly lives in the same directory as real herdr and treehouse, so exposing that + // directory would hand the suite straight back the tools it fakes. if err := os.Symlink(resolved, filepath.Join(dir, name)); err != nil { return "", err } @@ -46,14 +40,13 @@ func buildHermeticPath(dir string) (string, error) { return dir, nil } -// binDir returns a directory prepended to the current PATH for the rest of the -// test, so fake binaries written there are found first. Prepending keeps this -// additive - a test can call binDir twice and keep both fake dirs - and stays -// hermetic only because TestMain runs first: by then PATH is already -// hermeticPath, so there is no ambient PATH left to inherit here. +// Returns a directory prepended to the current PATH for the rest of the test, so fake binaries written +// there are found first. Prepending keeps this additive - a test can call binDir twice and keep both dirs. func binDir(t *testing.T) string { t.Helper() dir := t.TempDir() + // Hermetic only because TestMain runs first: by then PATH is already hermeticPath, so there is no ambient + // PATH left to inherit here. t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH")) return dir } @@ -66,14 +59,13 @@ func writeFakeBin(t *testing.T, dir, name, caseBody string) { } } -// writeFakeDispatch writes a fake binary that dispatches caseBody on selector -// (the shell expression each case arm matches, e.g. "$1" or "$1 $2") and fails -// loudly on any invocation shape the test did not anticipate. When logPath is -// non-empty every invocation is appended to it as " ", so a -// test can assert on which calls were and were not made. +// Writes a fake binary that dispatches caseBody on selector (the shell expression each case arm matches, +// e.g. "$1" or "$1 $2") and fails loudly on any invocation shape the test did not anticipate. func writeFakeDispatch(t *testing.T, dir, name, logPath, selector, caseBody string) { t.Helper() script := "" + // Every invocation is appended as " ", so a test can assert on which calls were and were + // not made. if logPath != "" { script = fmt.Sprintf("echo \"%s $@\" >> %s\n", name, shellSingleQuote(logPath)) } @@ -86,8 +78,8 @@ func shellSingleQuote(s string) string { return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" } -// herdrIDs is the fixed set of workspace/tab/pane identifiers a static fake -// herdr hands back for a single spawn/promote + teardown lifecycle. +// The fixed set of workspace/tab/pane identifiers a static fake herdr hands back for a single +// spawn/promote + teardown lifecycle. type herdrIDs struct { WorkspaceID string TabID string @@ -96,13 +88,13 @@ type herdrIDs struct { PaneStatus string // agent_status reported by "pane get"; defaults to "working" if empty } -// The herdr fake for a spawn (or promote) followed by a teardown within one test: -// no workspace exists yet, so the command creates one, and the create's response -// carries the root tab and pane herdr makes alongside it - the ones the task -// renames and reuses instead of creating a second tab. A test needing a workspace -// already open declares faketool.Herdr itself. +// The herdr fake for a spawn (or promote) followed by a teardown within one test: no workspace exists +// yet, so the command creates one, and the create's response carries the root tab and pane herdr makes +// alongside it - the ones the task renames and reuses instead of creating a second tab. func writeFakeHerdrStatic(t *testing.T, dir string, ids herdrIDs) { t.Helper() + // A test needing a workspace already open declares faketool.Herdr itself rather than going through + // here. writeFakeHerdrStaticLogged(t, dir, "", ids) } @@ -111,10 +103,9 @@ func writeFakeHerdrStatic(t *testing.T, dir string, ids herdrIDs) { // creating a second one, and that tearing that sole tab down closes the workspace. func writeFakeHerdrStaticLogged(t *testing.T, dir, logPath string, ids herdrIDs) { t.Helper() - // Real herdr creates a tab whenever it is asked to, but a generated fake has to - // know the identifiers up front, so a few spares stand ready for the second and - // later task spawned into the workspace the first one created. Running out is a - // loud failure, never a silent reuse of the first task's tab. + // Real herdr creates a tab whenever it is asked to, but a generated fake has to know the identifiers + // up front, so a few spares stand ready for the second and later task spawned into the workspace the + // first one created. Running out is a loud failure, never a silent reuse of the first task's tab. spares := make([]faketool.HerdrTab, 4) for i := range spares { spares[i] = faketool.HerdrTab{ @@ -132,33 +123,18 @@ func writeFakeHerdrStaticLogged(t *testing.T, dir, logPath string, ids herdrIDs) }.Install(t, dir) } -// writeFakeHerdrWatch writes a herdr fake for the watch scenario: workspace -// list always succeeds (satisfies watcher.Run's reachability probe), and -// "pane get " reports whatever status currently sits in statusDir/, -// letting the test drive independent, per-task status transitions while -// `hand watch` polls in the background just by rewriting one file per task. -// Both are query commands per internal/herdr/client.go's call() doc comment: -// real success is a non-null result object on exit 0, real failure a non-zero -// exit or an error envelope. "workspace list" always succeeds, mirroring the -// real success shape. "pane get" mirrors the real failure shape too - a -// diagnostic on stderr and a non-zero exit, the same contract -// cmd/status_test.go's writeFakeHerdrPaneStatus documents - whenever the -// published status is the sentinel "unreachable", so a test can take a pane -// dark and bring it back while the watcher polls. -// Each "pane get" is logged after the status read, never before: a test waiting on -// the Nth poll before publishing would otherwise still be racing that poll's read. -// The failing branch logs too, so waiting on the Nth probe works for a dark pane -// exactly as it does for a healthy one. -// -// The reported agent comes from statusDir/.agent and is empty unless a test -// calls setPaneAgent, which is what keeps a scenario that never mentions an agent -// out of every harness-capability path. "pane read" answers with the raw text in -// statusDir/.text - herdr's one command whose success shape is bare text on -// stdout rather than a result envelope, per client.go's PaneRead doc comment - and -// "pane send-text"/"pane send-keys" answer with the empty stdout of a void command. +// A herdr fake for the watch scenario: "workspace list" always succeeds, satisfying watcher.Run's +// reachability probe, and "pane get " reports whatever status sits in statusDir/, letting a test +// drive per-task transitions while `hand watch` polls in the background by rewriting one file per task. func writeFakeHerdrWatch(t *testing.T, dir, statusDir, logPath string) { t.Helper() + // Both are query commands per internal/herdr/client.go's call() doc comment: real success is a non-null + // result object on exit 0, real failure a non-zero exit plus a diagnostic on stderr (the same contract + // cmd/status_test.go's writeFakeHerdrPaneStatus documents), which the "unreachable" sentinel reproduces. quotedStatusDir, quotedLog := shellSingleQuote(statusDir), shellSingleQuote(logPath) + // The reported agent comes from statusDir/.agent, empty unless a test calls setPaneAgent, which keeps + // a scenario that never mentions an agent out of every harness-capability path. "pane read" answers with + // statusDir/.text - bare stdout, not a result envelope (client.go's PaneRead) - and the steers void. body := fmt.Sprintf(` "workspace list") echo '{"result":{"workspaces":[]}}' ;; "pane get") status=$(cat %s/"$3" 2>/dev/null || echo idle) @@ -177,19 +153,20 @@ func writeFakeHerdrWatch(t *testing.T, dir, statusDir, logPath string) { "pane send-text") echo "herdr pane send-text $3 $4" >> %s ;; "pane send-keys") echo "herdr pane send-keys $3 $4" >> %s ;;`, quotedStatusDir, quotedStatusDir, quotedLog, quotedLog, quotedStatusDir, quotedLog, quotedLog) + // Each "pane get" is logged after the status read, never before: a test waiting on the Nth poll before + // publishing would otherwise still be racing that poll's read. The failing branch logs too, so waiting on + // the Nth probe works for a dark pane - one taken down and brought back mid-poll - as for a healthy one. writeFakeDispatch(t, dir, "herdr", "", "$1 $2", body) } -// writeFakeHerdrSend writes a herdr fake for the send scenario: "pane get" -// reports whatever status currently sits in statusDir/, so a test can -// free a busy composer while `hand send` is waiting on it, and -// "pane send-text"/"pane send-keys" answer with the empty stdout real herdr -// gives a void command (client.go's callVoid doc comment). Every invocation is -// logged with the pid of the hand process that made it, which is what lets a -// test tell two concurrent senders apart; each pane status read is logged after -// the read for the same reason writeFakeHerdrWatch does it. +// A herdr fake for the send scenario: "pane get" reports whatever status sits in statusDir/, so a +// test can free a busy composer while `hand send` is waiting on it, and "pane send-text"/"pane send-keys" +// answer with the empty stdout real herdr gives a void command (client.go's callVoid doc comment). func writeFakeHerdrSend(t *testing.T, dir, statusDir, logPath string) { t.Helper() + // Every invocation is logged with the pid of the hand process that made it, which is what lets a test tell + // two concurrent senders apart; each pane status read is logged after the read, for the same reason + // writeFakeHerdrWatch does it. quotedLog := shellSingleQuote(logPath) body := fmt.Sprintf(` "pane get") status=$(cat %s/"$3" 2>/dev/null || echo idle) @@ -209,22 +186,21 @@ func writeFakeHerdrUnprobeablePanes(t *testing.T, dir string) { writeFakeDispatch(t, dir, "herdr", "", "$1 $2", body) } -// setPaneStatus publishes a pane's status by atomic rename: the fake herdr -// cats these files from a concurrently polling `hand watch`, and a truncating -// in-place write would let it read a phantom empty status mid-update. +// Publishes a pane's status by atomic rename: the fake herdr cats these files from a concurrently polling +// `hand watch`, and a truncating in-place write would let it read a phantom empty status mid-update. func setPaneStatus(t *testing.T, statusDir, paneID, status string) { t.Helper() publishPaneFile(t, statusDir, paneID, status) } -// setPaneAgent publishes which agent the fake reports running in a pane, driving -// every harness-capability path the watcher takes. +// Publishes which agent the fake reports running in a pane, driving every harness-capability path the +// watcher takes. func setPaneAgent(t *testing.T, statusDir, paneID, agent string) { t.Helper() publishPaneFile(t, statusDir, paneID+".agent", agent) } -// setPaneText publishes the scrollback the fake answers `pane read` with. +// Publishes the scrollback the fake answers `pane read` with. func setPaneText(t *testing.T, statusDir, paneID, text string) { t.Helper() publishPaneFile(t, statusDir, paneID+".text", text) @@ -241,23 +217,20 @@ func publishPaneFile(t *testing.T, statusDir, name, content string) { } } -// A one-slot treehouse pool at worktreePath, plus any paths it leased out before -// the test began - a scout's worktree a promote hands back, say, which the real -// pool would refuse as unmanaged if it were never declared. -// -// internal/faketool holds the pool: the slot is leased exclusively the way real -// treehouse's pool lock holds it, so a test cannot build the one fixture the real -// backend never produces - two live tasks on one slot - and prove the collision -// guard against a state that never occurs. +// A one-slot treehouse pool at worktreePath, plus any paths it leased out before the test began - a +// scout's worktree a promote hands back, say, which the real pool would refuse as unmanaged if it were +// never declared. func writeFakeTreehouse(t *testing.T, dir, worktreePath string, alreadyLeased ...string) { t.Helper() + // internal/faketool leases the slot exclusively, the way real treehouse's pool lock holds it, so no + // test can build two live tasks on one slot - a fixture the real backend never produces - and prove + // the collision guard against a state that never occurs. faketool.Treehouse{Slots: []string{worktreePath}, Held: alreadyLeased}.Install(t, dir) } -// The same one-slot pool as a treehouse older than v2.1.0: it leases and frees -// the slot identically but reports no lease_id at all, which is what drives -// worktree.CheckCollision down its path-comparison fallback - the same branch a -// task row written before the lease_id column existed takes. +// The same one-slot pool as a treehouse older than v2.1.0: it leases and frees the slot identically but +// reports no lease_id at all, which drives worktree.CheckCollision down its path-comparison fallback - +// the same branch a task row written before the lease_id column existed takes. func writeFakeTreehouseWithoutLeaseIdentity(t *testing.T, dir, worktreePath string) { t.Helper() faketool.Treehouse{ @@ -267,10 +240,9 @@ func writeFakeTreehouseWithoutLeaseIdentity(t *testing.T, dir, worktreePath stri }.Install(t, dir) } -// returnFakeWorktree frees a leased pool slot through the fake treehouse's own -// return arm. It stands in for the return `hand teardown` runs before it deletes -// the task's row: when that deletion fails, this is exactly the state left -// behind - the slot back in the pool, a row still naming it. +// Frees a leased pool slot through the fake treehouse's own return arm. It stands in for the return +// `hand teardown` runs before it deletes the task's row: when that deletion fails, this is exactly the +// state left behind - the slot back in the pool, a row still naming it. func returnFakeWorktree(t *testing.T, worktreePath string) { t.Helper() out, err := exec.Command("treehouse", "return", worktreePath).CombinedOutput() @@ -279,10 +251,9 @@ func returnFakeWorktree(t *testing.T, worktreePath string) { } } -// redirectGitRemote makes `git clone ` resolve to a local -// repo instead of the network, via git's url..insteadOf mechanism, -// appending the rule to the scratch config isolateGitConfig already points -// this test's git invocations at. +// Makes `git clone ` resolve to a local repo instead of the network, via git's +// url..insteadOf mechanism, appending the rule to the scratch config isolateGitConfig already +// points this test's git invocations at. func redirectGitRemote(t *testing.T, matchURL, localRepoPath string) { t.Helper() cfg := isolateGitConfig(t) diff --git a/tests/e2e/gate_visibility_test.go b/tests/e2e/gate_visibility_test.go index 9240788..1764839 100644 --- a/tests/e2e/gate_visibility_test.go +++ b/tests/e2e/gate_visibility_test.go @@ -12,15 +12,14 @@ import ( "github.com/atqamz/secondhand/internal/state" ) -// writeFakeNoMistakes writes a fake no-mistakes that answers `status` and `runs` with fixed text. -// `status` always exits 0, which is what the real binary does for every outcome hand reads from it - -// an uninitialized repo, a non-git directory and a healthy gate all print their answer behind exit 0 -// (verified against no-mistakes itself), so hand reads the text. `runs` carries its own exit code, -// since the real binary exits 1 on those same two refusals while printing the identical text. -// `init` is answered too, since `hand project add --mode no-mistakes` initializes the fresh clone's -// gate before anything can be dispatched into it. +// Writes a fake no-mistakes that answers `status` and `runs` with fixed text. `init` is answered too, since +// `hand project add --mode no-mistakes` initializes the fresh clone's gate before anything can be +// dispatched into it. func writeFakeNoMistakes(t *testing.T, dir, statusOut, runsOut string, runsExit int) { t.Helper() + // `status` always exits 0, which is what the real binary does for every outcome hand reads from it - an + // uninitialized repo, a non-git directory and a healthy gate all print their answer behind exit 0 + // (verified against no-mistakes itself), so hand reads the text. body := fmt.Sprintf(` status) printf '%%s\n' %s ;; runs) printf '%%s\n' %s; exit %d ;; init) echo 'gate initialized' ;;`, shellSingleQuote(statusOut), shellSingleQuote(runsOut), runsExit) @@ -32,9 +31,8 @@ const gateReadyStatus = " repo: /home/atqa/secondhand/projects/demo\n" + " gate: /home/atqa/.no-mistakes/repos/0b474f2021dd.git\n" + " daemon: running\n\n no active run" -// TestStatusEmptyFleetStatesItsCount covers atqamz/secondhand#100 on the real binary: an empty -// fleet must say so, and must still surface a hold left open on a torn-down task's id rather than -// reading as nothing to see. +// Covers atqamz/secondhand#100 on the real binary: an empty fleet must say so, and must still surface a +// hold left open on a torn-down task's id rather than reading as nothing to see. func TestStatusEmptyFleetStatesItsCount(t *testing.T) { home := newHome(t) @@ -69,11 +67,8 @@ func TestStatusEmptyFleetStatesItsCount(t *testing.T) { } } -// TestStatusFlagsAShippedPRThatNeverRanThroughTheGate covers atqamz/secondhand#92 through the -// operator's own sequence: spawn a ship task into a no-mistakes project, record its PR, let the -// worker report done, then read `hand status`. The gate holds no completed run for that PR, so -// both the fleet overview and the task's own detail view have to say so - and stop saying it the -// moment a completed run records that exact URL. +// Covers atqamz/secondhand#92 through the operator's own sequence: spawn a ship task into a no-mistakes +// project, record its PR, let the worker report done, then read `hand status`. func TestStatusFlagsAShippedPRThatNeverRanThroughTheGate(t *testing.T) { prURL := "https://github.com/owner/demo/pull/7" @@ -111,6 +106,8 @@ func TestStatusFlagsAShippedPRThatNeverRanThroughTheGate(t *testing.T) { t.Fatal(err) } + // The gate holds no completed run for that PR, so both the fleet overview and the task's own detail view + // have to say so. fleet := runHand(t, home, "status") if fleet.code != 0 { t.Fatalf("status: exit %d, stderr %q", fleet.code, fleet.stderr) @@ -129,6 +126,7 @@ func TestStatusFlagsAShippedPRThatNeverRanThroughTheGate(t *testing.T) { t.Fatalf("status --json stdout = %q (exit %d), want gate_run_issue", singleJSON.stdout, singleJSON.code) } + // And both have to stop saying it the moment a completed run records that exact URL. writeFakeNoMistakes(t, dir, gateReadyStatus, " completed ship-login-fix 758d72bf 2026-08-03 04:29 "+prURL, 0) @@ -141,11 +139,9 @@ func TestStatusFlagsAShippedPRThatNeverRanThroughTheGate(t *testing.T) { } } -// TestGateCheckNamesAMissingOrNonGitClonePath covers atqamz/secondhand#97 on both operator -// surfaces. A clone path that is missing, and one that exists but is not a git repository, are -// different failures from a gate that was never initialized: `no-mistakes init` repairs neither, -// so neither may be reported as "not initialized" nor - the worse outcome - pass as ready and let -// a worker be dispatched into a project the gate cannot cover. +// Covers atqamz/secondhand#97 on both operator surfaces. A clone path that is missing, and one that exists +// but is not a git repository, are different failures from a gate that was never initialized: `no-mistakes +// init` repairs neither. func TestGateCheckNamesAMissingOrNonGitClonePath(t *testing.T) { home := newHome(t) registerProject(t, home, "demo", "no-mistakes") @@ -156,6 +152,8 @@ func TestGateCheckNamesAMissingOrNonGitClonePath(t *testing.T) { writeFakeHerdrStatic(t, dir, herdrIDs{WorkspaceID: "ws-1", TabID: "tab-1", PaneID: "pane-1", Label: "demo"}) writeFakeNoMistakes(t, dir, "not in a git repository", "not in a git repository", 1) + // So neither may be reported as "not initialized" nor - the worse outcome - pass as ready and let a worker + // be dispatched into a project the gate cannot cover. clonePath := filepath.Join(home, "projects", "demo") // The clone directory does not exist at all: the chdir fails before no-mistakes ever runs, and diff --git a/tests/e2e/hold_test.go b/tests/e2e/hold_test.go index c0c830f..f39d6af 100644 --- a/tests/e2e/hold_test.go +++ b/tests/e2e/hold_test.go @@ -42,11 +42,9 @@ func decodeJSON(t *testing.T, got invocation, into any) { } } -// TestHoldLifecycle drives holds end to end through the built binary: set on a -// live task and on an id with no task row at all, rendered by every hand status -// surface, surviving the teardown of the task it was set on (the case a -// task-scoped hold could not cover), refusing the spawn that would reuse the -// id, and leaving nothing behind once cleared. +// Drives holds end to end through the built binary: set on a live task and on an id with no task row at +// all, rendered by every hand status surface, surviving the teardown of the task it was set on (the case a +// task-scoped hold could not cover), refusing the spawn that would reuse the id, cleared without a trace. func TestHoldLifecycle(t *testing.T) { home := newHome(t) registerProject(t, home, "demo", "local-only") diff --git a/tests/e2e/merge_test.go b/tests/e2e/merge_test.go index 388a0ae..a336ea3 100644 --- a/tests/e2e/merge_test.go +++ b/tests/e2e/merge_test.go @@ -13,10 +13,9 @@ import ( "github.com/atqamz/secondhand/internal/state" ) -// TestMergePR drives `hand merge` through a faked gh, no real remote: a task -// whose PR checks are all green merges cleanly, one whose checks include a -// failing bucket is refused before gh pr merge is ever invoked, and a rerun of -// the merge that succeeded is refused rather than merging a second time. +// Drives `hand merge` through a faked gh, no real remote: all-green checks merge cleanly, a failing +// bucket is refused before gh pr merge is ever invoked, and a rerun of the merge that succeeded is +// refused rather than merging a second time. func TestMergePR(t *testing.T) { home := newHome(t) registerProject(t, home, "demo", "direct-pr") @@ -31,11 +30,9 @@ func TestMergePR(t *testing.T) { dir := binDir(t) invocationLog := filepath.Join(t.TempDir(), "gh-invocations.log") - // prChecksGreen (cmd/merge.go) never consults gh's exit code once the "pr - // checks" JSON parses, so the always-exit-0 fake below exercises the same - // path a real fail-bucket exit 1 would. Each merge through it moves that PR - // to MERGED, which is what makes the rerun below see the state the first - // merge left rather than the state it started in. + // prChecksGreen (cmd/merge.go) never consults gh's exit code once the "pr checks" JSON parses, so the + // always-exit-0 fake below exercises the same path a real fail-bucket exit 1 would. Each merge through + // it moves that PR to MERGED, so the rerun sees the state the first merge left, not the initial one. faketool.GH{Log: invocationLog, PRs: []faketool.GHPR{ {Number: 1, Branch: "task-1-branch", Repo: "org/demo", Checks: []string{"pass"}}, {Number: 2, Branch: "task-2-branch", Repo: "org/demo", Checks: []string{"fail"}}, @@ -53,10 +50,9 @@ func TestMergePR(t *testing.T) { t.Fatalf("task-1 state = %+v, want MergeExecuted=true and MergeExecutedAt set", task1) } - // The row is written only after gh has merged, so a fault between the two leaves - // the PR merged and the row saying otherwise. The pre-check is then all that stops - // a rerun re-merging it, and a repeated `gh pr merge` is exit 0 with a warning - // (internal/faketool/FIDELITY.md), so nothing downstream would notice. + // The row is written only after gh has merged, so a fault between the two leaves the PR merged and the + // row saying otherwise. The pre-check is then all that stops a rerun, because a repeated `gh pr merge` + // is exit 0 with a warning (internal/faketool/FIDELITY.md) and nothing downstream would notice. task1.MergeExecuted = false task1.MergeExecutedAt = "" if err := state.Write(home, task1); err != nil { diff --git a/tests/e2e/project_test.go b/tests/e2e/project_test.go index 1bf5bb7..bdfb0d6 100644 --- a/tests/e2e/project_test.go +++ b/tests/e2e/project_test.go @@ -11,11 +11,9 @@ import ( "github.com/atqamz/secondhand/internal/project" ) -// TestProjectLifecycle drives add -> list -> sync (fast-forward) -> remove -// through the built binary against a real local git remote (redirected via -// git's insteadOf mechanism, never the network), plus the one failure path -// not already covered by TestExitCodeThreeOnPreconditionFailure: sync -// against a project registered but never actually cloned to disk. +// Drives add -> list -> sync (fast-forward) -> remove through the built binary against a real local git +// remote (redirected via git's insteadOf mechanism, never the network), plus the one failure path not +// already covered by TestExitCodeThreeOnPreconditionFailure: sync against a project never cloned to disk. func TestProjectLifecycle(t *testing.T) { remote := filepath.Join(t.TempDir(), "remote") initGitRepo(t, remote) diff --git a/tests/e2e/promote_test.go b/tests/e2e/promote_test.go index 3d097a2..5b936bb 100644 --- a/tests/e2e/promote_test.go +++ b/tests/e2e/promote_test.go @@ -12,11 +12,9 @@ import ( "github.com/atqamz/secondhand/internal/state" ) -// TestPromoteScoutToShip drives `hand promote` through the built binary: the -// brief-missing failure path first, then a clean promotion asserting the -// scout's old worktree/herdr identifiers are actually released (not just -// replaced) and the task's identity (ID, project, CreatedAt) carries over -// unchanged while its role fields (Kind, Worktree, Herdr) are fully replaced. +// Drives `hand promote` through the built binary: the brief-missing failure path first, then a clean +// promotion asserting the scout's old worktree/herdr identifiers are actually released (not just replaced) +// and that identity fields carry over unchanged while role fields are fully replaced. func TestPromoteScoutToShip(t *testing.T) { home := newHome(t) registerProject(t, home, "demo", "direct-pr") @@ -50,11 +48,9 @@ func TestPromoteScoutToShip(t *testing.T) { dir := binDir(t) newWorktree := filepath.Join(home, "wt-ship-new") invocationLog := filepath.Join(t.TempDir(), "invocations.log") - // Return (worktree.go) only checks CombinedOutput's error, never its - // content, on success, so the "ok" line stands in harmlessly for real - // treehouse return's actual (silent) output; "pane run" below is a void - // command whose real success is empty stdout (callVoid doc, client.go) - - // callVoid only checks env.Error, so the extra envelope body is harmless. + // Return (worktree.go) only checks CombinedOutput's error on success, never its content, so the "ok" line + // stands in harmlessly for real treehouse return's actual (silent) output; likewise "pane run" below is a + // void command whose real success is empty stdout (callVoid doc, client.go), and it checks only env.Error. writeFakeDispatch(t, dir, "treehouse", invocationLog, "$1", ` get) printf '{"path":"%s","lease_id":"lease-new"}\n' `+shellSingleQuote(newWorktree)+` ;; return) echo ok ;;`) // The scout's old workspace holds a second tab, so releasing the scout is a diff --git a/tests/e2e/report_watch_test.go b/tests/e2e/report_watch_test.go index ed73177..add8432 100644 --- a/tests/e2e/report_watch_test.go +++ b/tests/e2e/report_watch_test.go @@ -12,16 +12,9 @@ import ( "github.com/atqamz/secondhand/internal/state" ) -// TestWatchIdleAfterReportedNeedsDecisionIsNotDone proves the bug #30/#32 set -// out to kill: herdr's idle and done are the same "pane stopped being busy" -// signal (see herdr.Status's doc comment for why), and a worker's last -// reported state before that is what actually explains why. A working -> idle -// transition right after a "needs-decision" report must never be classified -// as done. This drives the fake to "done", not "idle": real herdr renders a -// working-or-blocked -> idle transition as "done" for a headless poller like -// hand, never "idle" (hand never focuses a client on a worker's pane, and only -// a focused client's active tab keeps herdr's own notification bookkeeping -// from collapsing that transition into "done"). +// Proves the bug atqamz/secondhand#30 and atqamz/secondhand#32 set out to kill: herdr's idle and done are +// the same "pane stopped being busy" signal (see herdr.Status's doc comment for why), so a working -> idle +// transition right after a "needs-decision" report must never be classified as done. func TestWatchIdleAfterReportedNeedsDecisionIsNotDone(t *testing.T) { home := newHome(t) registerProject(t, home, "demo", "direct-pr") @@ -47,6 +40,9 @@ func TestWatchIdleAfterReportedNeedsDecisionIsNotDone(t *testing.T) { if err := os.WriteFile(state.ReportPath(home, "task-1"), []byte("needs-decision: waiting on review\n"), 0o644); err != nil { t.Fatal(err) } + // Driven to "done", not "idle": real herdr renders a working-or-blocked -> idle transition as "done" for a + // headless poller like hand, never "idle" (hand never focuses a client on a worker's pane, and only a + // focused client's active tab keeps herdr from collapsing that transition into "done"). setPaneStatus(t, statusDir, "pane-1", "done") watch.waitForStdout(t, "needs-decision task-1: waiting on review", 5*time.Second) @@ -113,11 +109,8 @@ func TestWatchIdleWithNoReportIsSupervisorActionable(t *testing.T) { } } -// Workers report with a truncating redirect, so every report after the first -// rewrites the file in place over a line hand watch has already consumed. Two -// live samples from atqamz/secondhand#140, verbatim: both were announced as -// "malformed report" carrying a mid-word fragment of themselves, and neither -// contains anything a parser could object to. +// Workers report with a truncating redirect, so every report after the first rewrites the file in place +// over a line hand watch has already consumed. func TestWatchReportRewrittenInPlaceIsNotMalformed(t *testing.T) { home := newHome(t) registerProject(t, home, "demo", "direct-pr") @@ -140,6 +133,8 @@ func TestWatchReportRewrittenInPlaceIsNotMalformed(t *testing.T) { watch := startHandBackground(t, home, "watch", "--poll", "30ms") waitForInvocation(t, herdrLog, "herdr pane get pane-1", 5*time.Second) + // Two live samples from atqamz/secondhand#140, verbatim: both were announced as "malformed report" + // carrying a mid-word fragment of themselves, and neither contains anything a parser could object to. notes := []string{ "reading the ghutil call sites for --head", "gh confirmed --head takes plain branch name (qualified owner:branch returns nothing); implementing multi-repo search in ghutil", @@ -169,13 +164,9 @@ func TestWatchReportRewrittenInPlaceIsNotMalformed(t *testing.T) { } } -// Reports are one line of house-style prose, so a rewrite landing on exactly the -// byte count of the report it replaces is a matter of time rather than a contrived -// input - and it was skipped silently, because an offset at the end of the file -// with a newline behind it is what "nothing new" looks like too. The `done:` -// variant is the one that costs a completion: the deferred verification is gated -// on the last recorded report state, so a skipped done means a scout that finished -// is never announced as finished (atqamz/secondhand#149). +// Reports are one line of house-style prose, so a rewrite landing on exactly the byte count of the report +// it replaces is a matter of time rather than a contrived input - and it was skipped silently, because an +// offset at the end of the file with a newline behind it is what "nothing new" looks like too. func TestWatchDoneRewrittenToTheSameLengthReachesVerifiedDone(t *testing.T) { home := newHome(t) registerProject(t, home, "demo", "direct-pr") @@ -209,6 +200,9 @@ func TestWatchDoneRewrittenToTheSameLengthReachesVerifiedDone(t *testing.T) { } watch.waitForStdout(t, "working task-1: finishing the findings section", 5*time.Second) + // The `done:` variant is the one that costs a completion: the deferred verification is gated on the last + // recorded report state, so a skipped done means a scout that finished is never announced as finished + // (atqamz/secondhand#149). if err := os.WriteFile(state.ReportPath(home, "task-1"), []byte(done), 0o644); err != nil { t.Fatal(err) } diff --git a/tests/e2e/send_test.go b/tests/e2e/send_test.go index 8c43020..143d758 100644 --- a/tests/e2e/send_test.go +++ b/tests/e2e/send_test.go @@ -12,12 +12,9 @@ import ( "github.com/atqamz/secondhand/internal/state" ) -// TestConcurrentSendsToTheSameTaskSerialize drives two `hand send` processes at -// one busy pane. The send lock is cross-process by construction, so no -// cmd-level test can cover it: what has to hold is that one sender waits out -// the other's whole retry loop instead of both polling the same composer and -// firing their text into it at once, which is the lost-steer hazard -// atqamz/secondhand#102 traced. +// Drives two `hand send` processes at one busy pane. The send lock is cross-process by construction, so no +// cmd-level test can cover it: what has to hold is that one sender waits out the other's whole retry loop +// instead of both firing their text into the same composer at once (atqamz/secondhand#102's lost steer). func TestConcurrentSendsToTheSameTaskSerialize(t *testing.T) { home := newHome(t) registerProject(t, home, "demo", "direct-pr") @@ -86,10 +83,9 @@ func TestConcurrentSendsToTheSameTaskSerialize(t *testing.T) { } } -// TestSendRecordsAnUndeliveredSteerAndExitsSix covers the outcome an operator or -// a calling agent actually sees when a composer never frees: the documented exit -// code off the real process, and a trace of the abandoned message that outlives -// the process that tried to send it (SPECS.md, `hand send`). +// Covers the outcome an operator or a calling agent actually sees when a composer never frees: the +// documented exit code off the real process, and a trace of the abandoned message that outlives the process +// that tried to send it (SPECS.md, `hand send`). func TestSendRecordsAnUndeliveredSteerAndExitsSix(t *testing.T) { home := newHome(t) registerProject(t, home, "demo", "direct-pr") diff --git a/tests/e2e/slug_case_test.go b/tests/e2e/slug_case_test.go index fd78153..4bbdfd2 100644 --- a/tests/e2e/slug_case_test.go +++ b/tests/e2e/slug_case_test.go @@ -12,15 +12,14 @@ import ( "github.com/atqamz/secondhand/internal/state" ) -// writeFakeGHAnyCasing fakes `gh pr list` the way GitHub actually serves a repo: -// the same PR list comes back under every casing of the slug, so a search issued -// in the casing a clone's origin remote or a declared upstream happens to carry -// answers exactly as one issued in GitHub's canonical casing. A fake that matched -// --repo case-sensitively would answer a differently-cased search with an empty -// list and hide the very hit these tests are about. prRepoCasings are the casings -// of the one repo that holds the PR; every other repo answers empty. +// Fakes `gh pr list` the way GitHub actually serves a repo: the same PR list comes back under every casing +// of the slug, so a search issued in the casing a clone's origin remote or a declared upstream happens to +// carry answers exactly as one issued in GitHub's canonical casing. func writeFakeGHAnyCasing(t *testing.T, dir string, prRepoCasings []string, number int, url, prState, headRepo string) { t.Helper() + // A fake matching --repo case-sensitively would answer a differently-cased search with an empty list and + // hide the very hit these tests are about. prRepoCasings are the casings of the one repo that holds the + // PR; every other repo answers empty. item := fmt.Sprintf(`{"number":%d,"url":"%s","state":"%s","headRepository":{"nameWithOwner":"%s"}}`, number, url, prState, headRepo) patterns := make([]string, len(prRepoCasings)) @@ -35,13 +34,12 @@ func writeFakeGHAnyCasing(t *testing.T, dir string, prRepoCasings []string, numb writeFakeDispatch(t, dir, "gh", "", "$1 $2", caseBody) } -// setupCasedGateProject registers a project from remoteURL, gives it a task-1 -// worktree on a branch, and spawns task-1 with a commit on it - the state a -// no-mistakes gate leaves behind when it opens the PR itself, so t.PR is empty and -// hand has to find the PR by branch. It returns the fake-binary dir so the caller -// can write the gh fake that decides what that search finds. +// Registers a project from remoteURL, gives it a task-1 worktree on a branch, and spawns task-1 with a +// commit on it - the state a no-mistakes gate leaves behind when it opens the PR itself, so t.PR is empty +// and hand has to find the PR by branch. func setupCasedGateProject(t *testing.T, remoteURL, projectName string) (home, binaries string) { t.Helper() + // The fake-binary dir is returned so the caller can write the gh fake that decides what that search finds. remote := filepath.Join(t.TempDir(), "remote") initGitRepo(t, remote) redirectGitRemote(t, remoteURL, remote) @@ -67,12 +65,9 @@ func setupCasedGateProject(t *testing.T, remoteURL, projectName string) (home, b return home, binaries } -// TestPRRecordAcceptsCanonicalCasingOfOwnRepoAndUpstream drives the recording -// guard itself: a PR URL carries GitHub's canonical casing while the slugs it is -// checked against come from whatever casing the clone's origin remote and the -// declared upstream were written in. Both have to be accepted, and a repo nobody -// declared still refused - folding widens nothing, because a GitHub slug is unique -// only up to casing. +// Drives the recording guard itself: a PR URL carries GitHub's canonical casing while the slugs it is +// checked against come from whatever casing the clone's origin remote and the declared upstream were +// written in. func TestPRRecordAcceptsCanonicalCasingOfOwnRepoAndUpstream(t *testing.T) { remote := filepath.Join(t.TempDir(), "remote") initGitRepo(t, remote) @@ -116,16 +111,15 @@ func TestPRRecordAcceptsCanonicalCasingOfOwnRepoAndUpstream(t *testing.T) { } } + // Both casings above have to be accepted, and a repo nobody declared still refused: folding widens + // nothing, because a GitHub slug is unique only up to casing. foreign := runHand(t, home, "pr", "task-3", "https://github.com/someone/else/pull/1") assertInvocation(t, foreign, 3, "not project No-Mistakes's repo") } -// TestGateOpenedUpstreamPRFoundWhenOriginRemoteCasingDiffers drives the dropped -// fork PR through the built binary: the upstream search keeps only PRs whose head -// repo is the project's own, and gh reports that head repo in GitHub's canonical -// casing while hand derives it from whatever casing the clone's origin remote was -// written in. Compared case-sensitively the landed PR is discarded, so an operator -// sees "PR: (none)" and teardown refuses work that is already merged. +// Drives the dropped fork PR through the built binary: the upstream search keeps only PRs whose head repo +// is the project's own, and gh reports that head repo in GitHub's canonical casing while hand derives it +// from whatever casing the clone's origin remote was written in. func TestGateOpenedUpstreamPRFoundWhenOriginRemoteCasingDiffers(t *testing.T) { home, binaries := setupCasedGateProject(t, "https://github.com/Atqamz/No-Mistakes.git", "No-Mistakes") @@ -139,6 +133,8 @@ func TestGateOpenedUpstreamPRFoundWhenOriginRemoteCasingDiffers(t *testing.T) { []string{"KunchenGUID/No-Mistakes", "kunchenguid/no-mistakes"}, 597, upstreamPR, "MERGED", "atqamz/no-mistakes") + // Compared case-sensitively the landed PR is discarded, so an operator sees "PR: (none)" here and the + // teardown below refuses work that is already merged. status := runHand(t, home, "status", "task-1") if status.code != 0 { t.Fatalf("status: exit %d, stderr %q", status.code, status.stderr) @@ -156,10 +152,9 @@ func TestGateOpenedUpstreamPRFoundWhenOriginRemoteCasingDiffers(t *testing.T) { } } -// TestGateOpenedPRLandsWhenUpstreamDeclaresOwnRepoInOtherCasing drives the other -// half: an upstream declared as the project's own repo in different casing is the -// same repo, so searching it as a second target returns the one PR twice and the -// same-tier rule refuses landed work as ambiguous. +// Drives the other half: an upstream declared as the project's own repo in different casing is the same +// repo, so searching it as a second target returns the one PR twice and the same-tier rule refuses landed +// work as ambiguous. func TestGateOpenedPRLandsWhenUpstreamDeclaresOwnRepoInOtherCasing(t *testing.T) { home, binaries := setupCasedGateProject(t, "https://github.com/atqamz/no-mistakes.git", "no-mistakes") diff --git a/tests/e2e/spawn_teardown_test.go b/tests/e2e/spawn_teardown_test.go index 6d5e87b..2c00de9 100644 --- a/tests/e2e/spawn_teardown_test.go +++ b/tests/e2e/spawn_teardown_test.go @@ -12,11 +12,9 @@ import ( "github.com/atqamz/secondhand/internal/state" ) -// TestSpawnTeardownCycle drives a full spawn -> refused teardown -> local -// merge -> successful teardown cycle through the built binary, using a -// local-only project so the landed-work check exercises real git plumbing -// (a linked worktree merged into the clone's default branch) instead of a -// faked gh. +// Drives a full spawn -> refused teardown -> local merge -> successful teardown cycle through the built +// binary, using a local-only project so the landed-work check exercises real git plumbing (a linked +// worktree merged into the clone's default branch) instead of a faked gh. func TestSpawnTeardownCycle(t *testing.T) { home := newHome(t) registerProject(t, home, "demo", "local-only") diff --git a/tests/e2e/status_unacknowledged_test.go b/tests/e2e/status_unacknowledged_test.go index 875559f..b97351c 100644 --- a/tests/e2e/status_unacknowledged_test.go +++ b/tests/e2e/status_unacknowledged_test.go @@ -10,11 +10,9 @@ import ( "github.com/atqamz/secondhand/internal/state" ) -// TestStatusFlagsATerminalReportNoWatcherEverRead drives atqamz/secondhand#70 end -// to end: a worker that finished while no watcher was attached has to be visible -// to the next hand status, and has to stop being flagged once a watcher really -// consumed it. Only real processes prove the second half, because what clears the -// flag is the report_offset a watcher persisted to state/hand.db. +// Drives atqamz/secondhand#70 end to end: a worker that finished while no watcher was attached has to be +// visible to the next hand status, and has to stop being flagged once a watcher really consumed it. Only +// real processes prove the second half, because what clears the flag is a persisted report_offset. func TestStatusFlagsATerminalReportNoWatcherEverRead(t *testing.T) { home := seedOneTaskHome(t) diff --git a/tests/e2e/teardown_return_test.go b/tests/e2e/teardown_return_test.go index 8500c68..38c5a0a 100644 --- a/tests/e2e/teardown_return_test.go +++ b/tests/e2e/teardown_return_test.go @@ -14,22 +14,17 @@ import ( "github.com/atqamz/secondhand/internal/state" ) -// TestTeardownRefusesAnAbortedWorktreeReturn covers the one treehouse failure its -// exit status does not report: an unforced return of a dirty worktree prints the -// abort, exits 0, and leaves the slot leased (internal/faketool/FIDELITY.md). A -// scout reaches that return with dirt in place - its landed-work check reads the -// report on disk and never the worktree - so taking the abort for success would -// delete the row naming the leased slot and strand it in the pool for good. -// -// The forced retry is the second half: the first run already closed the task's tab, -// so the rerun must treat a tab herdr no longer lists as closed rather than reading -// the one tab left as this workspace's last and closing another task's workspace. +// The one treehouse failure its exit status does not report: an unforced return of a dirty worktree +// prints the abort, exits 0, and leaves the slot leased (internal/faketool/FIDELITY.md). func TestTeardownRefusesAnAbortedWorktreeReturn(t *testing.T) { home := newHome(t) registerProject(t, home, "demo", "local-only") worktree := filepath.Join(home, "wt-scout-1") initGitRepo(t, worktree) + // A scout reaches that return with dirt in place, because its landed-work check reads the report on + // disk and never the worktree. Taking the abort for success would delete the row naming the leased + // slot and strand it in the pool for good. if err := os.WriteFile(filepath.Join(worktree, "scratch.txt"), []byte("uncommitted\n"), 0o644); err != nil { t.Fatal(err) } @@ -64,6 +59,9 @@ func TestTeardownRefusesAnAbortedWorktreeReturn(t *testing.T) { t.Fatal("the pool leased the slot out again, so the return did happen and this proves nothing") } + // The second half: the first run already closed the task's tab, so this rerun has to treat a tab herdr + // no longer lists as closed rather than reading the one tab left as this workspace's last and closing + // another task's workspace. forced := runHand(t, home, "teardown", "scout-1", "--force") if forced.code != 0 { t.Fatalf("teardown --force: exit %d, stderr %q", forced.code, forced.stderr) diff --git a/tests/e2e/upstream_deliver_test.go b/tests/e2e/upstream_deliver_test.go index f0fa07a..34fb7b9 100644 --- a/tests/e2e/upstream_deliver_test.go +++ b/tests/e2e/upstream_deliver_test.go @@ -10,17 +10,11 @@ import ( "github.com/atqamz/secondhand/internal/state" ) -// TestForkContributionDeliveredNotLanded drives the whole case atqamz/secondhand#78 -// describes through the built binary: work pushed to a fork, its PR opened on an -// upstream repo the fleet does not control, and a maintainer who has not merged it. -// The upstream repo here is a fixture, never the live one - the real contribution -// is offered to a project Atqa only has read access to, so the case is -// constructed rather than reproduced. -// -// The two refusals in the middle are the point: hand pr rejects a repo nobody -// declared, and teardown rejects the still-open PR until the delivery is recorded. -// Reverting either half of the change turns one of them into a pass. +// Drives the whole case atqamz/secondhand#78 describes through the built binary: work pushed to a fork, its +// PR opened on an upstream repo the fleet does not control, and a maintainer who has not merged it. func TestForkContributionDeliveredNotLanded(t *testing.T) { + // The upstream repo here is a fixture, never the live one: the real contribution is offered to a project + // Atqa only has read access to, so the case is constructed rather than reproduced. remote := filepath.Join(t.TempDir(), "remote") initGitRepo(t, remote) redirectGitRemote(t, "https://github.com/atqamz/no-mistakes.git", remote) @@ -50,6 +44,8 @@ func TestForkContributionDeliveredNotLanded(t *testing.T) { } runGitIn(t, worktree, "commit", "--allow-empty", "-q", "-m", "fix the flake") + // The two refusals below are the point: hand pr rejects a repo nobody declared, and teardown rejects the + // still-open PR until the delivery is recorded. Reverting either half of the change turns one into a pass. upstreamPR := "https://github.com/kunchenguid/no-mistakes/pull/597" undeclared := runHand(t, home, "pr", "task-1", upstreamPR) assertInvocation(t, undeclared, 3, "no upstream is declared for it") diff --git a/tests/e2e/watch_ownership_test.go b/tests/e2e/watch_ownership_test.go index c046d5a..26158d4 100644 --- a/tests/e2e/watch_ownership_test.go +++ b/tests/e2e/watch_ownership_test.go @@ -14,11 +14,9 @@ import ( "github.com/atqamz/secondhand/internal/watcher" ) -// TestWatchIsASingletonPerFleetHome drives atqamz/secondhand#73 end to end with -// real processes, which is the only way to prove the contract that matters: a -// second watcher is refused rather than added to the fleet's pool of pollers, -// --takeover replaces a genuinely live incumbent that has to be signaled and -// reaped, and ownership then belongs to the replacement. +// Drives atqamz/secondhand#73 end to end with real processes, which is the only way to prove the contract +// that matters: a second watcher is refused rather than added to the fleet's pool of pollers, and +// --takeover replaces a genuinely live incumbent that has to be signaled and reaped. func TestWatchIsASingletonPerFleetHome(t *testing.T) { home := seedOneTaskHome(t) @@ -42,8 +40,8 @@ func TestWatchIsASingletonPerFleetHome(t *testing.T) { } waitForOwner(t, home, second.cmd.Process.Pid) - // The replacement is a full owner, not a squatter: it refuses a third watcher - // exactly as the incumbent it displaced did. + // Ownership then belongs to the replacement, a full owner rather than a squatter: it refuses a third + // watcher exactly as the incumbent it displaced did. third := runHand(t, home, "watch", "--poll", "30ms") if third.code != 3 || !strings.Contains(third.stderr, "pid "+strconv.Itoa(second.cmd.Process.Pid)) { t.Fatalf("third watch: exit %d stderr %q, want 3 naming pid %d", third.code, third.stderr, second.cmd.Process.Pid) @@ -51,19 +49,17 @@ func TestWatchIsASingletonPerFleetHome(t *testing.T) { second.stop(t, 10*time.Second) - // Ownership outlives no watcher: the lock has to be free again the moment the - // last one exits, or the next hand watch inherits a home it can never watch. + // Ownership outlives no watcher: the lock has to be free again the moment the last one exits, or the next + // hand watch inherits a home it can never watch. after := runHand(t, home, "watch", "--until-event", "--poll", "30ms", "--timeout", "100ms") if after.code != 4 { t.Fatalf("watch after the last watcher exited: exit %d, want 4 (no event), not an ownership refusal (stderr %q)", after.code, after.stderr) } } -// TestWatchStartsOverACrashedWatchersPidFile is the strand test: a watcher killed -// with SIGKILL never runs its own release, so the pid file it leaves behind holds -// a dead pid. The kernel drops its flock anyway, and the next watcher has to start -// with no operator intervention at all - a lock that refuses forever after a crash -// is worse than no lock. +// The strand test: a watcher killed with SIGKILL never runs its own release, so the pid file it leaves +// behind holds a dead pid. The kernel drops its flock anyway, and the next watcher has to start with no +// operator intervention at all - a lock that refuses forever after a crash is worse than no lock. func TestWatchStartsOverACrashedWatchersPidFile(t *testing.T) { home := seedOneTaskHome(t) @@ -90,9 +86,8 @@ func TestWatchStartsOverACrashedWatchersPidFile(t *testing.T) { } } -// seedOneTaskHome gives the watcher one task to poll, so waitForOwner is -// waiting on a loop that has really started rather than on a process that has -// merely been forked. +// Gives the watcher one task to poll, so waitForOwner is waiting on a loop that has really started rather +// than on a process that has merely been forked. func seedOneTaskHome(t *testing.T) string { t.Helper() home := newHome(t) diff --git a/tests/e2e/watch_test.go b/tests/e2e/watch_test.go index 0d573fa..7d01427 100644 --- a/tests/e2e/watch_test.go +++ b/tests/e2e/watch_test.go @@ -12,13 +12,8 @@ import ( "github.com/atqamz/secondhand/internal/state" ) -// TestWatchEventStream drives `hand watch` as a background process against -// two seeded tasks and asserts on its actual contract: a task's status at -// watch startup never retroactively fires an event (only a later transition -// does), events for distinct tasks appear on stdout in the order they -// occurred, and state/events.log durably records them so a consumer that -// starts reading only after the fact - the "late-starting consumer" case - -// still sees everything a live stdout reader saw. +// Drives `hand watch` as a background process against two seeded tasks and asserts on its actual contract: +// a task's status at watch startup never retroactively fires an event, only a later transition does. func TestWatchEventStream(t *testing.T) { home := newHome(t) registerProject(t, home, "demo", "direct-pr") @@ -43,22 +38,19 @@ func TestWatchEventStream(t *testing.T) { watch := startHandBackground(t, home, "watch", "--poll", "30ms") - // Both panes appearing in the invocation log proves the seeding tick - - // which per watcher.go only records status, never emits - has covered both - // tasks. Only after that can a status change be a genuine transition - // rather than a different seed value. + // Both panes appearing in the invocation log proves the seeding tick - which per watcher.go only records + // status, never emits - has covered both tasks. Only after that can a status change be a genuine + // transition rather than a different seed value. waitForInvocation(t, herdrLog, "herdr pane get pane-1", 5*time.Second) waitForInvocation(t, herdrLog, "herdr pane get pane-2", 5*time.Second) - // task-1's working -> not-busy transition is the liveness signal: seeing - // its event proves the watcher has run a post-seed tick over both tasks, - // so the absence of any task-2 output is a real suppression of task-2's - // pre-existing "blocked" status rather than a watcher that hasn't polled. - // Real herdr renders this transition as "done" - see herdr.Status's doc - // comment - and with no report on file it's unexplained, so it fires - // idle-unreported, not done. + // Real herdr renders task-1's working -> not-busy transition as "done" - see herdr.Status's doc comment - + // and with no report on file it is unexplained, so it fires idle-unreported rather than done. setPaneStatus(t, statusDir, "pane-1", "done") watch.waitForStdout(t, "idle-unreported task-1", 5*time.Second) + // task-1's event is the liveness signal: it proves the watcher has run a post-seed tick over both tasks, + // so the absence of any task-2 output is a real suppression of task-2's pre-existing "blocked" status + // rather than a watcher that has not polled yet. if strings.Contains(watch.stdout.String(), "task-2") { t.Fatalf("watch fired an event for task-2's pre-existing status before any transition: stdout=%q", watch.stdout.String()) } @@ -71,6 +63,7 @@ func TestWatchEventStream(t *testing.T) { setPaneStatus(t, statusDir, "pane-2", "blocked") watch.waitForStdout(t, "blocked task-2: agent needs help", 5*time.Second) + // Events for distinct tasks have to appear on stdout in the order they occurred. stdout := watch.stdout.String() idleUnreportedAt := strings.Index(stdout, "idle-unreported task-1") blockedAt := strings.Index(stdout, "blocked task-2") @@ -83,6 +76,8 @@ func TestWatchEventStream(t *testing.T) { t.Fatalf("hand watch exit = %d after SIGTERM, want 0 (stderr %q)", result.code, result.stderr) } + // The log has to hold them durably too, so a consumer that starts reading only after the fact still sees + // everything a live stdout reader saw. logData, err := os.ReadFile(filepath.Join(state.Dir(home), "events.log")) if err != nil { t.Fatalf("read events.log: %v", err) @@ -245,11 +240,9 @@ func TestWatchUntilEventDeliversParkedWhenTheReportChannelGoesSilent(t *testing. } } -// A done worker still attached to its pane is silence like any other: what severs -// a task from steering is the status file being torn down, not the worker's own -// last word, so done/failed are bounded under their own tier rather than exempt. -// The pane stays "working" throughout, so no herdr transition can produce this -// line - only the done-tier bound can. +// A done worker still attached to its pane is silence like any other: what severs a task from steering is +// the status file being torn down, not the worker's own last word, so done/failed are bounded under their +// own tier rather than exempt. func TestWatchParksADoneWorkerUnderItsOwnBound(t *testing.T) { home := newHome(t) registerProject(t, home, "demo", "direct-pr") @@ -268,6 +261,8 @@ func TestWatchParksADoneWorkerUnderItsOwnBound(t *testing.T) { } statusDir := t.TempDir() + // The pane stays "working" throughout, so no herdr transition can produce the parked line below - only + // the done-tier bound can. setPaneStatus(t, statusDir, "pane-1", "working") writeFakeHerdrWatch(t, binDir(t), statusDir, filepath.Join(t.TempDir(), "herdr-invocations.log")) @@ -285,20 +280,15 @@ func TestWatchParksADoneWorkerUnderItsOwnBound(t *testing.T) { } } -// The bug atqamz/secondhand#127 tracks, exercised through a real restart rather than -// through the latch alone: a done task's report file never grows again, so the -// silence parked fired against stays frozen, and a re-derived latch re-announces it -// on every re-arm. state/events.log is capped at 200 lines, so those duplicates -// evict real history - which is why the assertion counts lines in the log rather -// than only checking stdout, where the second run's duplicate would never have -// appeared anyway. +// The bug atqamz/secondhand#127 tracks, exercised through a real restart rather than through the latch +// alone: a done task's report file never grows again, so the silence parked fired against stays frozen, and +// a re-derived latch re-announces it on every re-arm. func TestWatchDoesNotRefireParkedForADoneTaskAcrossARestart(t *testing.T) { home := newHome(t) registerProject(t, home, "demo", "direct-pr") - // Wider than the streaming sibling's bound: --until-event arms with a probe - // and two stdout-discarding baseline ticks, and the bound has to outlast all - // of them under -race, or the parked line lands in the log while stdout is - // still discarded and the first run exits 4 with nothing delivered. + // Wider than the streaming sibling's bound: --until-event arms with a probe and two stdout-discarding + // baseline ticks, and the bound has to outlast all of them under -race, or the parked line lands in the + // log while stdout is still discarded and the first run exits 4 with nothing delivered. writeConfig(t, home, "parked-done-bound", "6") spawnedAt := time.Now().UTC().Format(time.RFC3339) @@ -321,6 +311,8 @@ func TestWatchDoesNotRefireParkedForADoneTaskAcrossARestart(t *testing.T) { if first.code != 0 { t.Fatalf("first watch exit = %d, want 0 for a delivered parked event (stdout %q, stderr %q)", first.code, first.stdout, first.stderr) } + // Counted in the log rather than only on stdout because state/events.log is capped at 200 lines, so a + // duplicate evicts real history; on stdout the second run's duplicate would never have appeared anyway. if got := countEventLogLines(t, home, "parked shipped-task"); got != 1 { t.Fatalf("events.log holds %d parked lines after the first run, want 1", got) } @@ -349,10 +341,9 @@ func countEventLogLines(t *testing.T, home, substr string) int { return count } -// The report channel produces a wake (`report-done`) long before the parked bound -// matures, so exiting on `parked` at all is the filter's doing: an unfiltered -// --until-event would have delivered that earlier wake and exited on it. The -// filter is stdout-only, so events.log still has to carry the wake it suppressed. +// The report channel produces a wake (`report-done`) long before the parked bound matures, so exiting on +// `parked` at all is the filter's doing: an unfiltered --until-event would have delivered that earlier wake +// and exited on it. func TestWatchUntilEventWakesOnlyOnTheFilteredKind(t *testing.T) { home := newHome(t) registerProject(t, home, "demo", "direct-pr") @@ -384,6 +375,7 @@ func TestWatchUntilEventWakesOnlyOnTheFilteredKind(t *testing.T) { t.Fatalf("stdout = %q, want the report-done wake filtered out: the caller named parked only", got.stdout) } + // The filter is stdout-only, so events.log still has to carry the wake it suppressed. logData, err := os.ReadFile(filepath.Join(state.Dir(home), "events.log")) if err != nil { t.Fatalf("read events.log: %v", err) @@ -393,10 +385,9 @@ func TestWatchUntilEventWakesOnlyOnTheFilteredKind(t *testing.T) { } } -// The notify template writes $HAND_MESSAGE straight to a marker file with no -// wrapper script of any kind, so a marker that ends up holding the exact event -// text is only possible if hand watch invoked config/notify in-process itself - -// the two hard requirements the wiring exists to satisfy. +// The notify template writes $HAND_MESSAGE straight to a marker file with no wrapper script of any kind, so +// a marker that ends up holding the exact event text is only possible if hand watch invoked config/notify +// in-process itself - the two hard requirements the wiring exists to satisfy. func TestWatchNotifiesInProcessForABlockedEvent(t *testing.T) { home := newHome(t) registerProject(t, home, "demo", "direct-pr") @@ -443,10 +434,9 @@ func TestWatchNotifiesInProcessForABlockedEvent(t *testing.T) { } } -// A task first sighted with its pane already unreachable - a re-scan picking up a -// fresh spawn - has no probed-to-unprobed edge to fire `failed` on, and used to be -// dropped from tracking entirely. The blink task is the other half of the contract: -// it answers again before the dwell matures and must produce nothing at all. +// A task first sighted with its pane already unreachable - a re-scan picking up a fresh spawn - has no +// probed-to-unprobed edge to fire `failed` on, and used to be dropped from tracking entirely. The blink +// task is the other half: it answers again before the dwell matures and must produce nothing at all. func TestWatchTracksATaskFirstSightedUnreachable(t *testing.T) { home := newHome(t) registerProject(t, home, "demo", "direct-pr") @@ -502,19 +492,9 @@ func TestWatchTracksATaskFirstSightedUnreachable(t *testing.T) { } } -// TestWatchResumesAUsageLimitedWorkerAndLeavesOthersAlone is the full-stack half of -// internal/watcher's usage-limit tests: a real `hand watch` process, a real sqlite -// state file, a real hold, and a fake herdr whose panes it reads and types into. -// -// Both tasks stop the same way and only one of them has a limit refusal on screen, so -// the untouched task is the control: it proves the resume is driven by what the harness -// printed rather than by the stop itself. -// -// The resume is split across two watch runs because the first attempt is deliberately -// never due within a test's lifetime - the floor is ten minutes. Moving the durable -// stamp into the past between runs is exactly the restart the schedule is persisted -// for, so this covers the durability too: a watcher that came up fresh still knows this -// worker is limited and when it may be poked. +// The full-stack half of internal/watcher's usage-limit tests: a real `hand watch` process, a real sqlite +// state file, a real hold, and a fake herdr whose panes it reads and types into. The resume is split +// across two watch runs because the first attempt is never due within a test's lifetime. func TestWatchResumesAUsageLimitedWorkerAndLeavesOthersAlone(t *testing.T) { home := newHome(t) registerProject(t, home, "demo", "direct-pr") @@ -534,6 +514,8 @@ func TestWatchResumesAUsageLimitedWorkerAndLeavesOthersAlone(t *testing.T) { setPaneAgent(t, statusDir, pane, "claude") setPaneStatus(t, statusDir, pane, "working") } + // Both tasks stop the same way and only one has a limit refusal on screen, so the untouched task is the + // control: it proves the resume is driven by what the harness printed rather than by the stop itself. setPaneText(t, statusDir, "pane-limited", "> resume\n\nClaude usage limit reached. Your limit will reset at 3pm (UTC).\n") setPaneText(t, statusDir, "pane-plain", "> resume\n\nI have finished the refactor.\n") @@ -589,8 +571,9 @@ func TestWatchResumesAUsageLimitedWorkerAndLeavesOthersAlone(t *testing.T) { t.Fatalf("herdr log = %q, want no pane steered before any attempt was due", data) } - // The restart: the stamp the first run wrote moved into the past, which is the one - // thing that makes an attempt due without waiting out the floor. + // The restart: the stamp the first run wrote moved into the past, which is the one thing that makes an + // attempt due without waiting out the ten-minute floor. It covers the durability too - a watcher that + // came up fresh still knows this worker is limited and when it may be poked. limited.UsageLimitRetryAt = time.Now().UTC().Add(-time.Minute).Format(time.RFC3339) if err := state.Write(home, limited); err != nil { t.Fatal(err) diff --git a/tools/commentlint/main.go b/tools/commentlint/main.go new file mode 100644 index 0000000..6dcee2c --- /dev/null +++ b/tools/commentlint/main.go @@ -0,0 +1,260 @@ +// Command commentlint enforces the two checkable comment rules CONTRIBUTING states: +// a comment may not open with the identifier it documents, and a comment block may +// not exceed three lines. +package main + +import ( + "fmt" + "go/ast" + "go/parser" + "go/token" + "io/fs" + "os" + "path/filepath" + "regexp" + "strings" +) + +const maxBlockLines = 3 + +type finding struct { + pos token.Position + msg string +} + +func main() { + roots := os.Args[1:] + if len(roots) == 0 { + roots = []string{"."} + } + var findings []finding + for _, root := range roots { + f, err := checkTree(root) + if err != nil { + fmt.Fprintln(os.Stderr, "commentlint:", err) + os.Exit(2) + } + findings = append(findings, f...) + } + for _, f := range findings { + fmt.Printf("%s:%d:%d: %s\n", f.pos.Filename, f.pos.Line, f.pos.Column, f.msg) + } + if len(findings) > 0 { + fmt.Fprintf(os.Stderr, "commentlint: %d violations\n", len(findings)) + os.Exit(1) + } +} + +func checkTree(root string) ([]finding, error) { + fset := token.NewFileSet() + var findings []finding + err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + if path != root && skipDir(d.Name()) { + return fs.SkipDir + } + return nil + } + if !strings.HasSuffix(path, ".go") { + return nil + } + f, err := checkFile(fset, path) + if err != nil { + return err + } + findings = append(findings, f...) + return nil + }) + return findings, err +} + +func skipDir(name string) bool { + return name == "vendor" || name == "testdata" || strings.HasPrefix(name, ".") || strings.HasPrefix(name, "_") +} + +func checkFile(fset *token.FileSet, path string) ([]finding, error) { + f, err := parser.ParseFile(fset, path, nil, parser.ParseComments|parser.SkipObjectResolution) + if err != nil { + return nil, err + } + if ast.IsGenerated(f) { + return nil, nil + } + return check(fset, f, path), nil +} + +func check(fset *token.FileSet, f *ast.File, path string) []finding { + var findings []finding + inTest := strings.HasSuffix(path, "_test.go") + + // Go's doc convention requires an exported doc comment to open with the identifier, + // so rule 1 can only apply where godoc does not reach: unexported declarations, + // everything in _test.go, and comments inside function bodies. + subject := func(doc *ast.CommentGroup, name *ast.Ident) { + if doc == nil || name == nil || (!inTest && name.IsExported()) { + return + } + if opensWith(doc, name.Name) { + findings = append(findings, finding{fset.Position(doc.Pos()), rule1msg(name.Name)}) + } + } + + declared := map[int][]string{} + ast.Inspect(f, func(n ast.Node) bool { + switch d := n.(type) { + case *ast.FuncDecl: + subject(d.Doc, d.Name) + case *ast.TypeSpec: + subject(d.Doc, d.Name) + case *ast.Field: + for _, name := range d.Names { + subject(d.Doc, name) + } + case *ast.ValueSpec: + for _, name := range d.Names { + subject(d.Doc, name) + } + case *ast.GenDecl: + // A single-spec declaration carries its comment on the GenDecl, not the spec. + if len(d.Specs) != 1 { + return true + } + switch s := d.Specs[0].(type) { + case *ast.TypeSpec: + subject(d.Doc, s.Name) + case *ast.ValueSpec: + for _, name := range s.Names { + subject(d.Doc, name) + } + } + case *ast.AssignStmt: + if d.Tok != token.DEFINE { + return true + } + for _, lhs := range d.Lhs { + addDeclared(fset, declared, lhs) + } + case *ast.RangeStmt: + addDeclared(fset, declared, d.Key) + addDeclared(fset, declared, d.Value) + case *ast.LabeledStmt: + addDeclared(fset, declared, d.Label) + } + return true + }) + + for _, g := range f.Comments { + // A package doc comment is a documentation surface godoc renders, not the + // in-body volume the rules target, so neither rule applies to it. + if g == f.Doc { + continue + } + for _, name := range declared[fset.Position(g.End()).Line+1] { + if opensWith(g, name) { + findings = append(findings, finding{fset.Position(g.Pos()), rule1msg(name)}) + } + } + if n := blockLines(fset, g); n > maxBlockLines { + findings = append(findings, finding{ + fset.Position(g.Pos()), + fmt.Sprintf("rule 2: comment block is %d lines, the limit is %d", n, maxBlockLines), + }) + } + } + + // A blank line above a doc comment does not start a new block either: splitting a long + // block that way leaves the same prose in front of the same declaration, and above an + // exported one it silently drops the first half out of godoc. + docs := docGroups(f) + for i := 1; i < len(f.Comments); i++ { + g, prev := f.Comments[i], f.Comments[i-1] + if !docs[g] || docs[prev] || fset.Position(g.Pos()).Column != 1 { + continue + } + if fset.Position(g.Pos()).Line-fset.Position(prev.End()).Line != 2 { + continue + } + if n := blockLines(fset, prev) + blockLines(fset, g); n > maxBlockLines { + findings = append(findings, finding{ + fset.Position(prev.Pos()), + fmt.Sprintf("rule 2: two blocks a blank line apart document one declaration, %d lines together, the limit is %d", n, maxBlockLines), + }) + } + } + return findings +} + +func docGroups(f *ast.File) map[*ast.CommentGroup]bool { + docs := map[*ast.CommentGroup]bool{} + add := func(g *ast.CommentGroup) { + if g != nil { + docs[g] = true + } + } + ast.Inspect(f, func(n ast.Node) bool { + switch d := n.(type) { + case *ast.FuncDecl: + add(d.Doc) + case *ast.GenDecl: + add(d.Doc) + case *ast.TypeSpec: + add(d.Doc) + case *ast.ValueSpec: + add(d.Doc) + case *ast.Field: + add(d.Doc) + } + return true + }) + return docs +} + +func rule1msg(name string) string { + return fmt.Sprintf("rule 1: comment opens with the identifier it documents (%q)", name) +} + +func addDeclared(fset *token.FileSet, declared map[int][]string, e ast.Expr) { + id, ok := e.(*ast.Ident) + if !ok || id.Name == "_" { + return + } + line := fset.Position(id.Pos()).Line + declared[line] = append(declared[line], id.Name) +} + +func opensWith(g *ast.CommentGroup, name string) bool { + for _, c := range g.List { + if isDirective(c.Text) { + continue + } + return leadingIdent(c.Text) == name + } + return false +} + +var identRun = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*`) + +func leadingIdent(text string) string { + text = strings.TrimPrefix(strings.TrimPrefix(text, "//"), "/*") + return identRun.FindString(strings.TrimSpace(text)) +} + +func blockLines(fset *token.FileSet, g *ast.CommentGroup) int { + n := 0 + for _, c := range g.List { + if isDirective(c.Text) { + continue + } + n += fset.Position(c.End()).Line - fset.Position(c.Pos()).Line + 1 + } + return n +} + +// Directives are not prose: the go toolchain's `//word:arg` form plus the linter +// suppressions that conventionally carry a leading space. +var directive = regexp.MustCompile(`^//(\s*#(no|go)sec|\+build |[a-z0-9]+:[^ ]|nolint\b)`) + +func isDirective(text string) bool { return directive.MatchString(text) } diff --git a/tools/commentlint/main_test.go b/tools/commentlint/main_test.go new file mode 100644 index 0000000..35806af --- /dev/null +++ b/tools/commentlint/main_test.go @@ -0,0 +1,286 @@ +package main + +import ( + "go/ast" + "go/parser" + "go/token" + "os" + "strings" + "testing" +) + +func findingsFor(t *testing.T, path, src string) []finding { + t.Helper() + fset := token.NewFileSet() + f, err := parser.ParseFile(fset, path, src, parser.ParseComments|parser.SkipObjectResolution) + if err != nil { + t.Fatalf("parse %s: %v", path, err) + } + if ast.IsGenerated(f) { + return nil + } + return check(fset, f, path) +} + +func TestRulesCatchTheShapeTheIssueMeasured(t *testing.T) { + cases := []struct { + name string + path string + src string + want string + }{ + { + name: "test function doc opening with its own name", + path: "slug_test.go", + src: `package p +// TestFoldsCasing drives the recording guard itself: a URL carries canonical +// casing while the slug does not. +func TestFoldsCasing(t *testing.T) {} +`, + want: `rule 1: comment opens with the identifier it documents ("TestFoldsCasing")`, + }, + { + name: "unexported helper doc opening with its own name", + path: "fakes_test.go", + src: `package p +// writeFakeGH fakes ` + "`gh pr list`" + ` the way GitHub serves a repo. +func writeFakeGH() {} +`, + want: `rule 1: comment opens with the identifier it documents ("writeFakeGH")`, + }, + { + name: "in-body comment opening with the variable it introduces", + path: "run.go", + src: `package p +func f() { + // want is the canonical casing, which gh returns whatever was asked for. + want := 1 + _ = want +} +`, + want: `rule 1: comment opens with the identifier it documents ("want")`, + }, + { + name: "four line block", + path: "run.go", + src: `package p +func f() { + // A differently cased search would answer empty and hide the hit, so the + // fake has to fold. GitHub serves the repo under every casing of the slug + // and the clone's origin remote decides which one a search is issued in, + // which is not the casing gh answers with. + g() +} +`, + want: "rule 2: comment block is 4 lines, the limit is 3", + }, + { + name: "blank comment line does not break the block", + path: "run.go", + src: `package p +func f() { + // The fake has to fold casing. + // + // GitHub serves the repo under every casing and the origin remote decides + // which one the search carries. + g() +} +`, + want: "rule 2: comment block is 4 lines, the limit is 3", + }, + { + name: "a blank line above a doc comment does not break the block either", + path: "run.go", + src: `package p + +// GitHub serves the repo under every casing of its slug, while the origin +// remote decides which one a search carries, so the fake has to fold casing +// the way the real tool does. + +// The blank line above satisfies the count and changes nothing a reader sees. +var v = 1 +`, + want: "rule 2: two blocks a blank line apart document one declaration, 4 lines together, the limit is 3", + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := findingsFor(t, c.path, c.src) + if len(got) != 1 { + t.Fatalf("got %d findings, want 1: %v", len(got), got) + } + if got[0].msg != c.want { + t.Errorf("got %q, want %q", got[0].msg, c.want) + } + }) + } +} + +// The negative cases decide whether the rules are adoptable: a checker that flags +// idiomatic Go, pragmas, or a real three-line WHY would be turned off on day one. +func TestRulesPassWhatMustKeepPassing(t *testing.T) { + cases := []struct { + name string + path string + src string + }{ + { + name: "three line why", + path: "run.go", + src: `package p +func f() { + // GitHub serves a repo under every casing of its slug, so a search issued in + // the casing the origin remote carries answers exactly as one issued in the + // canonical casing. Folding widens nothing: a slug is unique up to casing. + g() +} +`, + }, + { + name: "neighbouring declarations each documented", + path: "run.go", + src: `package p + +// The block above a documents a and nothing else, so the block above b is a +// second subject rather than a's second paragraph. Reading every such pair as +// one split block would flag most of the tree. +var a = 1 + +// Three lines here too, to prove the pair is judged by what each block +// documents and not by how long the two are together. +var b = 2 +`, + }, + { + name: "a split that stays inside the limit", + path: "run.go", + src: `package p + +// A one-line aside. + +// V is exported and documented. +var V = 1 +`, + }, + { + name: "pragmas", + path: "run.go", + src: `//go:build e2e + +package p + +//go:generate stringer -type=kind +//nolint:gosec // the path is repo-relative +// #nosec G404 +//lint:ignore SA1019 the replacement lands with the next release +var x = 1 +`, + }, + { + name: "idiomatic exported doc comment", + path: "run.go", + src: `package p +// Execute runs the root command. +func Execute() {} +// Kind is a task's lifecycle stage. +type Kind int +// ErrNoHome reports a missing fleet home. +var ErrNoHome = f() +`, + }, + { + name: "long package doc comment", + path: "run.go", + src: `// Package p records what the fleet knows about a task, which is four lines of +// prose because the invariants are the file's actual subject and godoc is where +// they are read. A worker reading the package for the first time needs them +// before it touches the store. +package p +`, + }, + { + name: "generated file", + path: "zz_generated.go", + src: `// Code generated by stringer. DO NOT EDIT. + +package p +// kind restates its own name across four lines, and none of it is checked +// because a generator wrote it and nobody edits it by hand, so flagging it +// would only ever produce noise no one can act on. +var kind = 1 +`, + }, + { + name: "comment not adjacent to the declaration it names", + path: "run.go", + src: `package p +func f() { + // want is set below, after the fake is in place. + + g() + want := 1 + _ = want +} +`, + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := findingsFor(t, c.path, c.src); len(got) != 0 { + t.Errorf("got %d findings, want 0: %v", len(got), got) + } + }) + } +} + +func TestTreeWalkSkipsNonGoAndReportsFileLine(t *testing.T) { + dir := t.TempDir() + write(t, dir+"/a.go", `package p +func f() { + // x names itself, which is the shape rule 1 is about. + x := 1 + _ = x +} +`) + write(t, dir+"/README.md", "// x is not Go\n") + got, err := checkTree(dir) + if err != nil { + t.Fatal(err) + } + if len(got) != 1 { + t.Fatalf("got %d findings, want 1: %v", len(got), got) + } + if !strings.HasSuffix(got[0].pos.Filename, "a.go") || got[0].pos.Line != 3 { + t.Errorf("got %s:%d, want a.go:3", got[0].pos.Filename, got[0].pos.Line) + } +} + +func TestTreeWalkNeverSkipsItsOwnRoot(t *testing.T) { + dir := t.TempDir() + "/.checkout" + if err := os.Mkdir(dir, 0o755); err != nil { + t.Fatal(err) + } + write(t, dir+"/a.go", `package p +func f() { + // x names itself, which is the shape rule 1 is about. + x := 1 + _ = x +} +`) + for _, root := range []string{dir, dir + "/", dir + "/."} { + got, err := checkTree(root) + if err != nil { + t.Fatalf("checkTree(%q): %v", root, err) + } + if len(got) != 1 { + t.Errorf("checkTree(%q) got %d findings, want 1: %v", root, len(got), got) + } + } +} + +func write(t *testing.T, path, src string) { + t.Helper() + if err := os.WriteFile(path, []byte(src), 0o644); err != nil { + t.Fatal(err) + } +}