Skip to content

fix(console): flags after the subcommand were ignored — watch --relaunch now actually relaunches, kill --force now forces - #401

Merged
patrickserrano merged 2 commits into
mainfrom
fix/console-flags-after-subcommand
Sep 18, 2026
Merged

patrickserrano merged 2 commits into
mainfrom
fix/console-flags-after-subcommand

Conversation

@patrickserrano

Copy link
Copy Markdown
Owner

Behaviour change

After this release, lacquer console … watch --relaunch really relaunches. That includes the form fleet-ops' README documents, ./console.sh watch --relaunch. Every Failed session in the sessions file is dispatched again, and each relaunch uses usage. Until now that command printed the status and exited 0 without relaunching anything, so anything that ran it on a schedule was doing nothing.

  • The fix(console): dispatch no longer fails with "not a terminal" (detached tmux, bypass permissions, recorded) and bg dispatch really creates a worktree #384 guard is unchanged. After 3 failed launches in a row (MaxFailedLaunches), a record is held and not relaunched again. Blocked and Missing sessions are still only reported.
  • To see what it would do first, run watch --relaunch --dry-run. That now works on either side of watch as well.
  • kill <name> --force now forces. Before, --force after the name was ignored, and an Alive session was refused as if it had not been passed.
  • An unknown flag, or a flag the subcommand has no use for, is now an error (exit 2). It used to be silently ignored or folded into the task. For example, --dry-run kill x used to kill for real, and dispatch proj t --dry-rn launched for real with t --dry-rn as the task (the RED run below caught exactly that). An unknown subcommand (console wacth) is now an error; before, it showed the dashboard. An extra argument to watch, kill, or inbox resolve/list is an error too.
  • --roster, --roles, --sessions and --inbox are still accepted by every subcommand, because fleet-ops' console.sh passes the first three on every call.

Why

Go's flag package stops at the first argument that isn't a flag. Every console subcommand is such an argument, so any flag written after it was never parsed. #399 made dispatch/dispatch-role refuse a known flag after the subcommand, and reported watch --relaunch / kill --force as still broken. Reproduced: with a Failed record, watch --relaunch --dry-run printed only the status, while --relaunch --dry-run watch attempted the relaunch.

What

cmd/lacquer/main.go:

  • parseConsoleArgs parses console flags wherever they appear: before the subcommand, after it, or among its arguments. It uses the flag package's own syntax: -name or --name, a value after = or as the next argument, and no separate value for a bool. So console --relaunch watch and console watch --relaunch mean the same thing. -- ends the flags. An unknown flag is an error; it never becomes an argument. This replaces feat(console): dispatch into a PM-assigned worktree (--worktree) or name the created branch (--branch) instead of an unused dispatch-* worktree #399's trailingFlag refusal.
  • consoleSubcommand resolves the subcommand ("" for the dashboard, inbox add, …). It refuses an unknown subcommand or an argument the subcommand would not read.
  • consoleFlagScope / checkConsoleFlagScope list which subcommands each flag applies to, and refuse a flag set for any other. A flag missing from the table is refused everywhere (fail closed), and TestConsoleEveryFlagHasADeclaredScope checks that every flag console -h lists has an entry. This takes over feat(console): dispatch into a PM-assigned worktree (--worktree) or name the created branch (--branch) instead of an unused dispatch-* worktree #399's "--worktree/--branch only with dispatch" check.
  • The inbox flags (--type --title --body --ref --project, --all) are now console flags scoped to inbox add / inbox list, so they also work on either side. runInboxAdd/runInboxList take the parsed values.
  • lacquer help and site/.../reference/commands.md now say flags work on either side, list which ones every subcommand accepts, and explain --. The --live note ("run watch --relaunch once to relaunch") needed no change: its advice is correct now, and wasn't before.

Dispatch: flags after the project are now parsed, not refused

I chose to parse them, with -- as the only way to put a flag-looking word in a task:

Tests

In cmd/lacquer/console_flag_order_test.go. Every test goes through run(), using #399's newPlaceFleet: gittest repos, a fake claude and tmux on PATH, and a temp HOME. Nothing real is launched or killed.

  • watch: a Failed record, run 5 ways (flags before, after, split, --relaunch=true, file flags after too). All must print the same dry-run relaunch, launch nothing, and leave the sessions file unchanged. --relaunch=false must not relaunch. A real watch --relaunch must launch once and replace the failed record.
  • kill: an Alive bg record (its job state says working). kill proj is refused. --force before, after, between, and --force=true must each kill it: the job dir is removed and the record dropped.
  • dispatch / dispatch-role: dry runs with --mode/--branch/--dry-run/--roster/--roles/--worktree on either side, and among the task words, must all print identical output. Nothing launches, nothing is recorded, and no worktree is created.
  • --: words after -- are the task, flag-looking ones included. Without --, an unknown flag in the task is an error that names --. After --, --dry-run is task text (the fake launch receives try --dry-run).
  • inbox: add, resolve and list --all with --inbox and their own flags on either side.
  • Refusals (23 cases): unknown flags before and after each subcommand and on the dashboard; a flag outside its scope (--dry-run/--relaunch with kill, --force with watch, --mode with dispatch-role, --relaunch with dispatch, inbox flags on the wrong inbox subcommand, --type with watch, --force with the dashboard); a missing value, a bad bool, an unknown subcommand, and extra arguments. Each must exit non-zero and name the offending word. Across all of them there must be no launch, the Alive job must still be there, and the sessions file and inbox must be unchanged.
  • TestConsoleDispatchRefusesAmbiguousOrMisplacedPlacement (feat(console): dispatch into a PM-assigned worktree (--worktree) or name the created branch (--branch) instead of an unused dispatch-* worktree #399): its three "flag after dispatch is refused" cases now cover allowed behaviour, tested positively above. They are replaced by --worktree after watch and --branch= after kill, which must still be refused.

RED on main (fc481d1): every new test failed. The refusal test also showed claude launched 2 time(s): on main, dispatch proj t --dry-rn and dispatch-role pm-bg -x both launched for real.

Mutation testing (CLAUDE.md rule 2)

For each mutation: break main.go, run go test ./cmd/lacquer -run 'TestConsole|TestInbox', restore with git checkout, then confirm git status is clean. All 18 were killed by a named test. M10 and M17 broke the build on the first pass, which doesn't count, so both were rewritten to compile and run again.

# Mutation Failed test(s)
M1 -- not a terminator TestConsoleDispatchDoubleDashEndsTheFlags
M2 -- drops what follows TestConsoleDispatchDoubleDashEndsTheFlags
M3 flags stop at the first argument (the old behaviour) 9 tests, incl. TestConsoleWatchRelaunchAfterTheSubcommandRelaunches, TestConsoleKillForceAfterTheSubcommandForces
M4 a bool consumes the next argument 16 tests
M5 a bool ignores its =value TestConsoleWatchRelaunchAfterTheSubcommandRelaunches, TestConsoleRefusesAnUnknownOrMisplacedFlag
M6 a value flag does not consume its value 21 tests
M7 an unknown flag becomes an argument TestConsoleRefusesAnUnknownOrMisplacedFlag, TestConsoleDispatchDoubleDashEndsTheFlags
M8 a missing value is ignored TestConsoleRefusesAnUnknownOrMisplacedFlag
M9 an invalid value is ignored TestConsoleRefusesAnUnknownOrMisplacedFlag
M10 scope never checked TestConsoleRefusesAnUnknownOrMisplacedFlag, TestConsoleDispatchRefusesAmbiguousOrMisplacedPlacement
M11 --dry-run scoped to kill too TestConsoleRefusesAnUnknownOrMisplacedFlag
M12 an unscoped flag allowed TestConsoleEveryFlagHasADeclaredScope
M13 extra arguments accepted TestConsoleRefusesAnUnknownOrMisplacedFlag
M14 an unknown subcommand shows the dashboard TestConsoleRefusesAnUnknownOrMisplacedFlag
M15 no -- remedy in the error TestConsoleDispatchDoubleDashEndsTheFlags
M16 --force scoped to watch TestConsoleKillForceAfterTheSubcommandForces, TestConsoleRefusesAnUnknownOrMisplacedFlag
M17 inbox list ignores --all TestConsoleInboxFlagsParseOnEitherSide, TestConsoleInboxAddListResolveRoundTrips
M18 --type scoped everywhere TestConsoleRefusesAnUnknownOrMisplacedFlag

Verification

Run locally the way .github/workflows/ci.yml runs it, after merging origin/main (#400, v1.41.0):

  • gofmt -l . produced no output. go vet ./... and go build ./... passed.
  • go test <all but internal/shipped> -race -timeout 15m with LACQUER_TEST_REQUIRE_TMUX=1 exited 0: 41 packages, 39 ok, 2 with no tests.
  • go test ./internal/shipped/ -timeout 20m: ok (85.2s).

No detector package changed and no profiles/*/workflows/ changed, so the fleet dry-run and proven-on sections don't apply. VERSION is untouched.

@patrickserrano
patrickserrano merged commit 1bcb83a into main Sep 18, 2026
5 checks passed
@patrickserrano
patrickserrano deleted the fix/console-flags-after-subcommand branch September 18, 2026 12:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant