Skip to content

fix(cli): honor -w/--worktree in one-shot mode instead of silently ignoring it - #67476

Open
Sora-bluesky wants to merge 2 commits into
NousResearch:mainfrom
Sora-bluesky:fix/issue-67458
Open

fix(cli): honor -w/--worktree in one-shot mode instead of silently ignoring it#67476
Sora-bluesky wants to merge 2 commits into
NousResearch:mainfrom
Sora-bluesky:fix/issue-67458

Conversation

@Sora-bluesky

Copy link
Copy Markdown
Contributor

What does this PR do?

hermes -z "..." -w accepted the worktree flag but silently ignored it: both run_oneshot(...) call sites dropped it — the main dispatch (hermes_cli/main.py) and the Termux fast-CLI path — so the agent ran in the current checkout and its commits landed directly on the live branch. That's data-loss-adjacent for scripts that relied on -z ... -w for isolation, and it's the exact surprise the flag exists to prevent.

This PR forwards the flag from both call sites and mirrors interactive chat's worktree lifecycle inside run_oneshot(): prune stale worktrees → create the disposable worktree → retarget TERMINAL_CWD at it for the run → clean up in the finally (which also covers KeyboardInterrupt/SystemExit, since they re-raise after it). Setup runs before the stdout/stderr redirect so errors reach the terminal, and a requested-but-failed setup exits 2 instead of silently running without isolation — the same refusal interactive chat makes (cli.py returns rather than run unisolated).

Two deliberate divergences from interactive, documented in the docstring:

  • The cleanup helper's output is routed to stderr. One-shot's contract is that stdout carries ONLY the final response, and _cleanup_worktree's "has unpushed commits, keeping" notice fires precisely in -w's main use case (the agent committed something) — unredirected it would corrupt out=$(hermes -z ... -w) pipelines.
  • The worktree: true config default is not honored. One-shot is script-facing; isolation stays opt-in per invocation via the explicit flag.

TERMINAL_CWD is restored afterward so in-process callers don't inherit a dangling path to the removed worktree.

Related Issue

Fixes #67458

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)

Changes Made

  • hermes_cli/oneshot.pyworktree parameter on run_oneshot(); setup/refusal before the output redirect; stderr-routed cleanup + TERMINAL_CWD restore in the finally.
  • hermes_cli/main.py — forward worktree=getattr(args, "worktree", False) at both run_oneshot(...) call sites (main dispatch and Termux fast path).
  • tests/hermes_cli/test_oneshot_worktree.py — new: success path (agent runs with TERMINAL_CWD retargeted, cleanup called, stdout carries only the response, no dangling env), requested-but-failed refusal (exit 2, agent never runs), setup-exception hard error, and default-off passthrough (worktree machinery untouched). The first three fail against the previous code.

How to Test

  1. scripts/run_tests.sh tests/hermes_cli/test_oneshot_worktree.py tests/hermes_cli/test_oneshot_usage_file.py -q
  2. Manual repro (from the issue): in a git repo, hermes -z "make a commit adding a line to README" -w → the commit lands on a dedicated hermes/wt-* branch in a disposable worktree; the checked-out branch is untouched; stdout contains only the agent's final response.

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits
  • I searched for existing PRs to make sure this isn't a duplicate (topic search: worktree × one-shot — none; no linked PRs on the issue)
  • My PR contains only changes related to this fix/feature
  • I've run scripts/run_tests.sh tests/hermes_cli/test_oneshot_worktree.py tests/hermes_cli/test_oneshot_usage_file.py — 10/10 pass on my platform
  • I've added tests for my changes
  • I've tested on my platform: Windows 11. Branch is based on current main; scripts/check-windows-footguns.py --diff is clean.

Documentation & Housekeeping

  • Docstrings updated (run_oneshot documents the isolation contract and both divergences) — or N/A
  • cli-config.yaml.example — N/A (no config surface added; the config default's non-application is documented in the docstring)
  • CONTRIBUTING.md / AGENTS.md — N/A
  • Cross-platform impact — reuses the existing cross-platform worktree helpers from cli.py; no new OS-touching code
  • Tool descriptions/schemas — N/A

Screenshots / Logs

$ scripts/run_tests.sh tests/hermes_cli/test_oneshot_worktree.py tests/hermes_cli/test_oneshot_usage_file.py -q
=== Summary: 2 files, 10 tests passed, 0 failed (100% complete) ===

# Against the unfixed code (regression proof):
3 failed, 1 passed  (only the default-off passthrough passes, as expected)

🤖 Generated with Claude Code

@alt-glitch alt-glitch added type/bug Something isn't working comp/cli CLI entry point, hermes_cli/, setup wizard P2 Medium — degraded but workaround exists needs-decision Awaiting maintainer decision before any implementation sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades labels Jul 19, 2026
@alt-glitch

Copy link
Copy Markdown
Collaborator

This was generated by AI during triage.

Related to #67471 for #67458: this PR implements isolated one-shot worktrees, whereas #67471 rejects the combination. Maintainer choice is needed; neither patch is a duplicate.

@teknium1 teknium1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The underlying bug is present on current main: both one-shot dispatches omit worktree (hermes_cli/main.py:12970-12976, 15127-15133). The reuse of the existing worktree helpers is a reasonable salvage direction, but this version has blocking one-shot-contract issues.

Problems

  • cli._setup_worktree() prints successful setup output to stdout (cli.py:1630-1632). The new call occurs before run_oneshot() redirects output, so hermes -z ... -w pollutes machine-readable stdout.
  • TERMINAL_CWD is captured after from cli import ...; importing cli force-exports the local cwd (cli.py:603-655). That cannot restore an in-process caller's original value.
  • Existing exact dispatch assertions at tests/hermes_cli/test_tui_resume_flow.py:377-383 and :616-622 do not account for the new keyword.

Suggested changes

  • Capture state before importing cli; redirect setup/cleanup helper output to stderr; test setup output as well as cleanup output.
  • Update both existing dispatch tests and add explicit -w forwarding coverage.

Automated hermes-sweeper review.

Comment thread hermes_cli/oneshot.py Outdated

repo = _git_repo_root()
if repo:
_prune_stale_worktrees(repo)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_setup_worktree() prints its successful creation banner to stdout (cli.py:1630-1632), but this call is before the one-shot redirect. Route setup output to stderr too, otherwise successful -z -w pipelines receive banner text before the final response.

Comment thread hermes_cli/main.py Outdated
@@ -12982,6 +12982,7 @@ def _try_termux_fast_cli_launch() -> bool:
provider=getattr(args, "provider", None),
toolsets=getattr(args, "toolsets", None),
usage_file=getattr(args, "usage_file", None),
worktree=getattr(args, "worktree", False),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please update the exact captured-kwargs assertion in tests/hermes_cli/test_tui_resume_flow.py::test_termux_fast_cli_launch_oneshot_uses_light_parser and add the corresponding -w forwarding case. This new keyword otherwise makes that existing test fail.

@teknium1 teknium1 added sweeper:risk-platform-windows Sweeper risk: may break or behave differently on native Windows sweeper:blast-contained Sweeper blast radius: contained — one narrow path / opt-in / few users labels Jul 19, 2026
@Sora-bluesky

Copy link
Copy Markdown
Contributor Author

All three points addressed in 7bd338678, plus three more from an independent gpt-5.6 review of the delta:

  1. Setup output → stderr. The prune/setup helpers now run under redirect_stdout(sys.stderr) — same contract as the cleanup path; the success test asserts stdout carries only the final response with both helper messages on stderr.
  2. Capture before the import. TERMINAL_CWD is captured before from cli import ... (whose config bridge force-exports it), and every exit path after the import — setup exception, setup returning None, and the normal finally — restores it through one helper. The early-return leak is covered by a test.
  3. Dispatch assertions updated + -w coverage added for both call sites (top-level and the Termux fast path), each asserting worktree=True forwarding.

@Sora-bluesky

Copy link
Copy Markdown
Contributor Author

Both points are addressed in the current branch: the worktree setup and cleanup output now go to stderr (commit "keep one-shot stdout clean during worktree setup"), so -z -w stdout stays the agent's final text only; and the captured-kwargs assertion plus the -w forwarding case are updated (test_termux_fast_cli_launch_oneshot_forwards_worktree, test_main_top_level_oneshot_forwards_worktree). Ready for another look.

@Sora-bluesky

Copy link
Copy Markdown
Contributor Author

Thanks for the detailed review. It was against commit 5df0d1641a; the current head resolves all three, in a commit titled "keep one-shot stdout clean during worktree setup; restore TERMINAL_CWD":

  • Setup and cleanup helpers now run inside with redirect_stdout(sys.stderr): (oneshot.py:263 for setup, :338 for cleanup), so -z -w keeps stdout machine-readable. test_oneshot_worktree.py pins it: stdout is the response, the banner goes to stderr.
  • TERMINAL_CWD is captured before the from cli import (oneshot.py:240, ahead of the import at :251) and restored on every post-import exit path.
  • Both exact dispatch assertions now carry "worktree": False (test_tui_resume_flow.py:406 and :661), plus two forwarding tests at :1921 and :1957.

Flagging since the salvageability=medium verdict was on the earlier commit.

@GottZ

GottZ commented Aug 3, 2026

Copy link
Copy Markdown

This was generated by AI during triage.

Summary

Three open PRs address #67458 by forwarding -w/--worktree through both one-shot dispatch paths and adding worktree setup and cleanup. #67471 and #67476 implement fail-closed isolation with regression tests but differ in runtime-CWD handling, while #67484 leaves the reviewed stdout and environment-restoration defects in its visible diff.

Related pull requests

Duplicates

#67471 and #67476 substantially implement the same honor--w solution; #67484 is an incomplete implementation of that same issue scope.

Suggested consolidation

Keep #67476 open with a salvage path: rebase onto current main or split out its two-path forwarding, fail-closed worktree lifecycle, stderr routing, TERMINAL_CWD restoration, and focused regression tests. Close #67471 as a duplicate of #67476 despite its earlier keep_open review because its revised diff now pursues the same isolation design while lacking #67476's explicit runtime-CWD handling; close #67484 as a duplicate of #67476 because its visible diff retains the contributor-reviewed stdout and environment-restoration defects.

Complex graph

flowchart LR
    classDef open fill:#dbeafe,stroke:#1d4ed8,color:#1e3a8a
    classDef merged fill:#dcfce7,stroke:#15803d,color:#14532d
    classDef closed fill:#e5e7eb,stroke:#6b7280,color:#1f2937
    classDef unverified fill:#f3f4f6,stroke:#9ca3af,color:#374151
    classDef best stroke-width:3px,stroke:#b45309
    classDef target stroke-width:3px,stroke:#4338ca
    I67458(["issue #67458 (open)"])
    subgraph Dup67471 ["PRs duplicating each other"]
        P67471["PR #67471 (open)"]
        P67476["PR #67476 (open)"]
        P67484["PR #67484 (open)"]
    end
    P67476 -->|best fix| I67458
    class I67458 open
    class P67471 open
    class P67476 open
    class P67484 open
    class P67476 best
    class P67476 target
    click I67458 "https://github.com/NousResearch/hermes-agent/issues/67458"
    click P67471 "https://github.com/NousResearch/hermes-agent/pull/67471"
    click P67476 "https://github.com/NousResearch/hermes-agent/pull/67476"
    click P67484 "https://github.com/NousResearch/hermes-agent/pull/67484"
Loading

Graph: solid arrow = fixes / best fix, dashed arrow = partial or unverified (see edge label); boxed group = PRs duplicating each other; amber border = best fix; indigo border = target; gray node = closed (state tag in the node label).

Cross-PR triage: Reviewed 3 pull requests and 1 issue in this complex. Each diff was read against this issue; Assessment working set: 72 kB of PR diffs, 11 kB of issue/PR text, 10 kB of discussion (14 comments), 5 verify verdicts. verdicts reflect diff content, not PR titles. Part of an automated triage batch.

@alt-glitch alt-glitch removed needs-decision Awaiting maintainer decision before any implementation sweeper:risk-platform-windows Sweeper risk: may break or behave differently on native Windows labels Aug 17, 2026
Sora-bluesky and others added 2 commits August 20, 2026 23:22
…noring it

hermes -z accepted -w but never forwarded it: all three
_run_and_exit_oneshot() call sites (the main dispatch, the Termux
fast-CLI path and the fast chat-launch path) dropped the flag,
so the agent ran in the current checkout and its commits landed directly
on the live branch — the exact surprise the flag exists to prevent, and
data-loss-adjacent for scripts that relied on '-z ... -w' for isolation.

Forward the flag and mirror interactive chat's worktree lifecycle in
run_oneshot(): prune stale worktrees, create the disposable worktree,
retarget TERMINAL_CWD at it for the run, and clean up in the finally
(which also covers KeyboardInterrupt/SystemExit — they re-raise after
it). Setup runs BEFORE the stdout/stderr redirect so errors reach the
terminal, and a requested-but-failed setup exits 2 instead of silently
running without isolation — same refusal interactive chat makes.

Two deliberate divergences from interactive, documented in the
docstring:
- The cleanup helper's print() output is routed to stderr: one-shot's
  contract is that stdout carries ONLY the final response, and the
  'has unpushed commits, keeping' notice fires precisely in -w's main
  use case (the agent committed something).
- The 'worktree: true' config default is not honored; one-shot is
  script-facing, so isolation stays opt-in per invocation.

TERMINAL_CWD is restored afterward so in-process callers don't inherit
a dangling path to the removed worktree.

Tests: success path (TERMINAL_CWD retargeted during the run, cleanup
called, stdout purity, no dangling env), requested-but-failed refusal,
setup-exception hard error, and default-off passthrough. The first
three fail against the previous code.

Fixes NousResearch#67458

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ERMINAL_CWD on every exit

Review follow-ups on NousResearch#67476 (hermes-sweeper + gpt-5.6-sol):

- _setup_worktree()'s success output ('Worktree created: ...') printed to
  stdout before run_oneshot's redirect, polluting the machine-readable
  stream — route the prune/setup helpers through
  redirect_stdout(sys.stderr), matching the cleanup path's contract.
- TERMINAL_CWD is now captured BEFORE 'from cli import ...': the import's
  config bridge force-exports the variable, so a post-import capture could
  never restore an in-process caller's original value.
- Every exit path after that import — setup exception, setup returning
  None, and the normal finally — restores the captured value via one
  helper; previously the early returns leaked the force-exported value.
- The exact-kwargs dispatch assertions in test_tui_resume_flow.py now
  include worktree=False, and both dispatch paths gain explicit
  worktree=True forwarding tests (top-level and Termux fast path).
- Worktree tests re-pin TERMINAL_CWD after entering their patch context
  (mock.patch('cli....') itself imports cli, which force-exports the
  variable), so the exact-restore assertions hold in any test order. A
  faithful cold-import-ordering test isn't possible under module caching;
  the failure-path restore tests pin the observable contract instead.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/cli CLI entry point, hermes_cli/, setup wizard P2 Medium — degraded but workaround exists sweeper:blast-contained Sweeper blast radius: contained — one narrow path / opt-in / few users sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

-w/--worktree is silently ignored in one-shot mode (-z): commits land on the current branch

4 participants