diff --git a/.claude/settings.json b/.claude/settings.json index 6aa1afb..4b9fabd 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -38,7 +38,9 @@ "mcp__hermit__review_pr", "mcp__hermit__close_issue", "mcp__hermit__run_requirements_sweep", - "mcp__hermit__now" + "mcp__hermit__now", + "mcp__hermit__get_loop_state", + "mcp__hermit__update_loop_state" ] } } diff --git a/README.md b/README.md index 3abff98..1ba2b3b 100644 --- a/README.md +++ b/README.md @@ -207,6 +207,25 @@ GitHub Issue created No further action is needed. HERMIT handles the entire development workflow autonomously. +### Alternative to Step 2: `hermit run` (no Claude Code session required) + +`/hermit` keeps the Superintendent loop alive inside a Claude Code session — a terminal running `claude` has to stay open for as long as you want autonomous operation. `hermit run` moves that loop into HERMIT's own long-lived process instead: it owns an internal ticker and, once per tick, launches `claude -p` non-interactively (no permission prompts, `--dangerously-skip-permissions`), waits for it to finish, then waits `[agent].loop_interval` before the next tick. `hermit serve` is already a long-lived process (the MCP server); `hermit run` is the same shape applied to the Superintendent cycle itself. + +```sh +cd your-project # directory where hermit init was run +hermit run +``` + +`hermit run` and `/hermit` are not mutually exclusive — use whichever fits how you want to keep HERMIT alive: + +- `hermit pause` / `hermit resume` / `hermit quit` / `hermit status` all work the same way regardless of which one is driving the loop; `hermit run` checks `.hermit-paused`/`.hermit-quit` itself before every tick. +- `hermit run` sends SIGINT/SIGTERM a graceful shutdown: an in-flight pass is never interrupted — it always finishes, and only then does the loop stop. +- A pass that hangs or takes a long time never causes overlapping ticks: the next tick is only scheduled after the previous one returns. +- If `N` consecutive passes fail, `hermit run` sends a notification via `[notification]` (`[run].failure_notify_threshold`, default 3) so unattended failures don't go unnoticed. +- The three cadence timestamps the Superintendent cycle tracks (last PR-comment check, last Issue-comment check, last requirements sweep) live in `.hermit/superintendent-state.json`, owned by HERMIT's Go code and read/written only through the `get_loop_state`/`update_loop_state` MCP tools — never hand-written by the Superintendent session itself. + +See "Running HERMIT Continuously" below for keeping `hermit run` alive across reboots/logouts on each platform. + ### Superintendent Cycle 1. Retrieve open Issues with `list_issues` @@ -238,6 +257,10 @@ No further action is needed. HERMIT handles the entire development workflow auto | `get_config` | Returns current HERMIT configuration values (e.g. `loop_interval`) | | `notify` | Sends a notification to the configured webhook (Slack, Discord, or generic) | | `get_default_branch` | Returns the repository's default branch name | +| `now` | Returns the current wall-clock time (RFC3339), authoritative for cadence tracking | +| `get_loop_state` | Returns the cadence timestamps (`pr_comments_since`, `issue_comments_since`, `requirements_sweep_since`) and `hermit run` liveness fields (`last_success_tick`, `consecutive_failures`) from `.hermit/superintendent-state.json` | +| `update_loop_state` | Updates one or more of the three cadence timestamps in `.hermit/superintendent-state.json`; the only supported way to write that file besides `hermit run` itself | +| `run_requirements_sweep` | Reconciles REQUIREMENTS.md against test results, opening Issues for unimplemented/regressed requirements | ### Risk Evaluation Criteria @@ -291,6 +314,9 @@ engineer = "claude-sonnet-5" # model used for Engineer roles # [notification] # webhook_url = "https://hooks.slack.com/services/..." # Slack, Discord, or generic webhook # type = "slack" # "slack" | "discord" | "generic" (auto-detected from URL if omitted) + +# [run] +# failure_notify_threshold = 3 # `hermit run`: consecutive failed passes before a webhook notification (default: 3) ``` **Pass `GITHUB_TOKEN` as an environment variable. Do not write it in `harness.toml`.** @@ -417,6 +443,7 @@ When the score drops below 70, a lesson is generated and saved to `.hermit/lesso ``` hermit serve # Start the MCP server (stdio) — Claude Code auto-starts this, manual execution normally not needed +hermit run # Start the Superintendent tick loop as a standalone long-lived process (no Claude Code session needed) hermit install # Register MCP server via `claude mcp add` and install slash commands hermit init # Initialize a project (generate harness.toml, CLAUDE.md, issue template, settings) hermit pause # Pause autonomous operation (resumable) @@ -463,6 +490,99 @@ Run this once after upgrading from an older version if you see warnings about le --- +## Running HERMIT Continuously + +`hermit run` (see "Alternative to Step 2" above) is a plain foreground process: it runs until it receives SIGINT/SIGTERM, a `.hermit-quit` file appears, or it's killed. To keep it running unattended across reboots, logouts, or crashes, use your platform's normal process-supervision tooling — HERMIT intentionally does not generate or install any of these for you (see REQ-019/REQ-014: HERMIT stays a thin toolbox, not a process manager). + +### systemd (Linux) + +Create a user service, e.g. `~/.config/systemd/user/hermit-run.service`: + +```ini +[Unit] +Description=HERMIT Superintendent loop + +[Service] +WorkingDirectory=/path/to/your-project +ExecStart=/path/to/hermit run +Restart=on-failure +Environment=GITHUB_TOKEN=... + +[Install] +WantedBy=default.target +``` + +```sh +systemctl --user daemon-reload +systemctl --user enable --now hermit-run.service +journalctl --user -u hermit-run -f # follow logs +``` + +### launchd (macOS) + +Create `~/Library/LaunchAgents/com.hermit.run.plist`: + +```xml + + + + + Labelcom.hermit.run + ProgramArguments + + /path/to/hermit + run + + WorkingDirectory/path/to/your-project + RunAtLoad + KeepAlive + StandardOutPath/tmp/hermit-run.log + StandardErrorPath/tmp/hermit-run.err + + +``` + +```sh +launchctl load ~/Library/LaunchAgents/com.hermit.run.plist +``` + +### Windows Service + +Use a lightweight wrapper such as [WinSW](https://github.com/winsw/winsw) or [NSSM](https://nssm.cc/) to register `hermit.exe run` (with `WorkingDirectory` set to your project) as a Windows service — HERMIT does not register itself as one. + +### tmux / screen + +The simplest option for a single long-running session: + +```sh +tmux new -d -s hermit 'cd /path/to/your-project && hermit run' +tmux attach -t hermit # to check on it later +``` + +### Docker + +```dockerfile +FROM golang:1-alpine AS build +# ... build the hermit binary ... + +FROM alpine +RUN apk add --no-cache git github-cli nodejs npm \ + && npm install -g @anthropic-ai/claude-code +COPY --from=build /out/hermit /usr/local/bin/hermit +WORKDIR /project +ENTRYPOINT ["hermit", "run"] +``` + +```sh +docker run -d --name hermit-run \ + -e GITHUB_TOKEN=... -e ANTHROPIC_API_KEY=... \ + -v /path/to/your-project:/project \ + your-hermit-image +docker logs -f hermit-run +``` + +--- + ## License MIT diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index 2676612..e97680b 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -21,7 +21,7 @@ ## 現状把握サマリ (2026-07 時点) -全 14 要件のうち **実装済み 14 件 / 一部実装 0 件 / 未実装 0 件**。設計文書 HERMIT.md の骨格はすべて実装されており、多くの領域で設計を超えて拡張されています。一方、HERMIT.md 自体が実装に追従しておらず、以下の差分 (設計文書の記述と実際の実装の乖離) があります。 +全 17 要件のうち **実装済み 17 件 / 一部実装 0 件 / 未実装 0 件**。設計文書 HERMIT.md の骨格はすべて実装されており、多くの領域で設計を超えて拡張されています(Issue #181 の `hermit run` (REQ-019) を含む)。一方、HERMIT.md 自体が実装に追従しておらず、以下の差分 (設計文書の記述と実際の実装の乖離) があります。 | # | 差分 | 設計 (HERMIT.md) | 実装 (現状) | |---|---|---|---| @@ -176,13 +176,13 @@ Superintendent が同時に生成する Engineer の数は `harness.toml` の `[ MADFLOW の教訓 (HERMIT.md §1, §9) に基づき、HERMIT では以下を行わない。 -- Go バイナリが Claude Code をサブプロセスとして起動・管理する「外側から包む」アーキテクチャ +- Go バイナリが Claude Code をサブプロセスとして起動・管理する「外側から包む」アーキテクチャ (ただし REQ-019 `hermit run` は明示的な例外— 下記注記参照) - エージェンティックループ・LLM API 呼び出しの Go による再実装 - チャットログ (ファイル) 経由のエージェント間通信 — Agent tool の入出力で代替する - タイマーによるコンテキストリセット等のコンテキスト/プロセスライフサイクル管理 — Claude Code に委譲する -- 受け入れ条件: 上記に該当する実装がコードベースに追加されていないこと (レビューで担保) +- 受け入れ条件: 上記に該当する実装 (ただし REQ-019 で明示的に許可された `hermit run` の範囲を除く) がコードベースに追加されていないこと (レビューで担保) - verify: manual -- 実装状況: 実装済み (遵守) — 現行コードベースに該当実装は存在しない。`cmd/hermit` のサブコマンドは serve / install / init / doctor / upgrade / version 等の CLI に留まり、Claude Code のプロセス管理や LLM 呼び出しは行っていない +- 実装状況: 実装済み (遵守、Issue #181 による限定的な例外あり) — `cmd/hermit` のサブコマンドは serve / run / install / init / doctor / upgrade / version 等の CLI に留まり、Claude Code のプロセス管理や LLM 呼び出しの再実装は行っていない。唯一の例外が REQ-019 の `hermit run`: `claude -p` を子プロセスとして起動する「外側から包む」形に見えるが、(1) エージェンティックループ・コンテキスト管理・LLM API 呼び出しの再実装は一切行わず、1 tick = 1 回の `claude -p` 起動を待つだけの薄いラッパーに留まる、(2) これは Issue #147 (Superintendent をバックグラウンドサブエージェント化する設計) の再導入ではなく、Issue #171 で実証された「サブエージェント spawn 上限の枯渇によるループの静かな停止」という失敗モードを構造的に回避するための独立した設計 (3 回目の再設計) である、という理由により、この 1 行に限り本要件の対象から明示的に除外する。詳細は REQ-019 を参照 ## REQ-015: 制御面パスの evaluate_risk 既定 HIGH 昇格 @@ -204,7 +204,6 @@ reconcile sweep の review-test は、要件の「仕様」が変わったとき - 受け入れ条件: `Requirement.Hash` が `受け入れ条件` と `verify` のみから計算され、要件ブロック全体からは計算されないこと。`実装状況` 行のみを変更しても次の sweep で review-test が発火しないこと。`受け入れ条件` の変更、および `verify` の `test` ↔ `manual` の切り替えは従来どおり発火すること。見出しや説明文のみの変更では発火しないこと。ハッシュストアに計算方式のバージョンが記録され、方式変更後の初回 sweep は全件を再計算・保存するのみで Issue を起票しないこと - verify: test - 実装状況: 実装済み — `internal/requirements/requirements.go` の `specHash` が `AcceptanceCriteria` と `Verify` のみからハッシュを計算するように変更 (旧 `hashText(block)` を置き換え)。`internal/requirements/hashstore.go` の `HashStore` インターフェースを `Load() (version int, hashes map[string]string, err error)` / `Save(version int, hashes map[string]string) error` に拡張し、`HashSchemeVersion` 定数 (現在値 2) を導入。旧形式 (バージョン無しの素の map) のファイルは version 0 として扱われ後方互換。`internal/requirements/sweep.go` の `Sweep` は読み込んだバージョンが `HashSchemeVersion` と異なる場合 `schemeChanged` として HashChanged 判定を強制的に false にし (review-test を発火させず)、sweep 終了時に現行バージョンでハッシュを保存し直すことで移行を1回のsweepで完了させる。自己増殖ループの回帰テストは `internal/requirements/sweep_test.go` の `TestSweep_ImplementationStatusOnlyChange_DoesNotFireReviewTest`、スキーマ移行の回帰テストは同ファイルの `TestSweep_HashSchemeMigration_DoesNotFireReviewTest_JustRecomputesAndSaves`、ハッシュ計算自体の単体テストは `internal/requirements/requirements_test.go` の `TestParse_HashUnaffectedByImplementationStatusField` / `TestParse_HashUnaffectedByTitleOrDescriptionOnly` / `TestParse_HashChangesWithVerifyMode` で検証。REQ-ID 命名規約に沿った `TestREQ016_ReviewTestHashIgnoresImplementationStatus` を追加 - ## REQ-017: list_issues は信頼できる author_association の Issue のみを返す HERMIT は public リポジトリで運用され得るため、第三者が作成した Issue の本文がそのまま Engineer への指示としてローカルで実行されることを防ぐ。`ListOpenIssues` / `ListAllIssues` は GitHub API の `author_association` を参照し、信頼できる association を持つ Issue のみを返す。信頼する association は `harness.toml` の `[security] trusted_author_associations` で設定可能で、既定値は `OWNER` / `MEMBER` / `COLLABORATOR` の 3 種のみ (`CONTRIBUTOR` / `FIRST_TIME_CONTRIBUTOR` / `NONE` は含めない)。 @@ -227,3 +226,13 @@ Engineer は `Bash(*)` 許可でローカルマシン上で動作しており、 - 受け入れ条件: `hermit init` が生成する `.claude/settings.json` に上記構造の `sandbox` ブロックが含まれ、`allowUnsandboxedCommands: false` / `GITHUB_TOKEN` の `mode: mask` + `injectHosts` が満たされていること。生成された設定を適用した状態で `go build ./...` / `go test ./...` が成功し、`gh pr create` 相当の操作が実行できること。`hermit doctor` が上記 3 種の警告を検出すること。既存プロジェクトへの `hermit init` 再実行が既存の `permissions` 設定を破壊しないこと - verify: test - 実装状況: 実装済み — `internal/permissions/permissions.go` の `DefaultSandboxSettings` / `MergeDefaultSettings` (再実行時は既存のトップレベルキーを保持し、欠けているキーのみ補完)、`cmd/hermit/main.go` の `writeClaudeSettings` (`MergeDefaultSettings` を経由するよう変更)、`cmd/hermit/doctor.go` の `checkSandboxSettings` (3 種の警告)。テストは `internal/permissions/permissions_test.go` の `TestREQ018_*` 群 (`DefaultSandboxSettings` の `allowUnsandboxedCommands`/`GITHUB_TOKEN`/Go ツールチェーン許可ドメイン、`MergeDefaultSettings` の新規生成・既存 `permissions`/`sandbox` の保持・エラー経路) と `cmd/hermit/doctor_test.go` / `cmd/hermit/unit_test.go` の `TestREQ018_*` 群 (`checkSandboxSettings` の警告条件、`writeClaudeSettings` の再実行時非破壊)。`go build ./...` と `go test ./...` の成功、および `gh pr create` 相当操作の実行可能性は本 Issue #180 の PR 自体 (生成された設定下で `go test ./...` を通し、同じ worktree から `gh pr create` で PR を作成) によって実地検証済み。README の "Sandboxing the Engineer" セクションにスコープ precedence・managed settings・Issue #179 依存の記載を追加 + +## REQ-019: `hermit run` — Superintendent ループを Claude Code セッションの外に出す + +Issue #181。無人運用のために利用者が `claude` を起動し `/hermit` を打ったセッションを永続的に保持し続ける必要がある、という #147→#172 を経てもなお残っていた制約を取り除くため、`hermit run` サブコマンドを追加する。長寿命の Go プロセスが内部 ticker を持ち、`hermit serve` が MCP サーバとして長寿命プロセスであるのと同じ形で、毎 tick `claude -p` を 1 回起動して完了を待つ。 + +サブエージェント方式 (#147) への回帰は明示的に禁止する — #171 で「バックグラウンドサブエージェントを cron tick ごとに spawn し続けると、長時間運用でセッションの spawn 上限を静かに枯渇させる」という失敗モードが実証済みのため、`hermit run` は Claude Code のサブエージェントを一切 spawn しない設計でなければならない。 + +- 受け入れ条件: (1) `hermit run` サブコマンドが存在し usage 出力に記載されている、(2) 内部 ticker で周期実行し前のパスが完了してから `[agent].loop_interval` (既定 270 秒) 待って次のパスを開始する、(3) パスが長時間かかっても重複起動しない、(4) 各 tick はプロジェクトルートを cwd として `claude -p` を非対話 (permission prompt でハングしない) で起動する、(5) `.hermit/superintendent-state.json` を Go 側が所有し、PR コメント確認/Issue コメント確認/要件スイープの 3 つの since タイムスタンプは `get_loop_state`/`update_loop_state` MCP ツール経由でのみ読み書きされる、(6) 最終成功 tick 時刻を記録し設定可能な N 回連続失敗で webhook 通知する、(7) `.hermit-paused`/`.hermit-quit` を `hermit run` 自身が検知する、(8) SIGINT/SIGTERM を受けても実行中のパスを中断せずグレースフルに停止する、(9) systemd unit / launchd plist / Windows サービス登録などの OS 固有コードは追加せず常駐化方法は README の案内に留める、(10) Windows amd64 でビルド・動作する、(11) `/hermit` スラッシュコマンドは廃止せず併存させる +- verify: test +- 実装状況: 実装済み — `internal/runloop` (`Run`/`Options`) が ticker・重複起動防止 (1 ループ内で Invoke を直列実行するのみで並行呼び出しの余地がない構造)・グレースフルシャットダウン (shutdown context はパス開始前とインターバル待機中のみ参照し、実行中の Invoke には渡さない)・`.hermit-paused`/`.hermit-quit` 検知・失敗連続カウントに応じた webhook 通知を実装。`internal/state` が `.hermit/superintendent-state.json` の読み書き (load-modify-save、一時ファイル+rename によるアトミック書き込み) を所有。`internal/mcp/tools.go` の `get_loop_state`/`update_loop_state` ツールが 3 つの since タイムスタンプを internal/state 経由で読み書きし、Superintendent サイクルが同ファイルを直接書き込む経路は存在しない。`cmd/hermit/main.go` の `cmdRun`/`newClaudeInvoker`/`buildClaudeRunArgs` が `hermit run` サブコマンド本体・非対話 `claude` 起動 (`--dangerously-skip-permissions`)・SIGINT/SIGTERM (`signal.NotifyContext`) を実装し、`go build`/`go test` を `GOOS=windows GOARCH=amd64` で実行して Windows ビルドを確認済み。systemd/launchd/Windows サービス/tmux/Docker の常駐化案内は README「Running `hermit run` Continuously」節に追記。テストは `internal/runloop/runloop_test.go`・`internal/state/state_test.go`・`internal/mcp/req_test.go`・`cmd/hermit/run_test.go` の `TestREQ019_*` diff --git a/cmd/hermit/main.go b/cmd/hermit/main.go index a6ed891..e84d3af 100644 --- a/cmd/hermit/main.go +++ b/cmd/hermit/main.go @@ -2,25 +2,31 @@ package main import ( "bufio" + "context" "embed" "fmt" "log" "os" "os/exec" + "os/signal" "path/filepath" "strconv" "strings" + "syscall" "text/template" + "time" "github.com/BurntSushi/toml" "github.com/ytnobody/hermit/internal/git" gh "github.com/ytnobody/hermit/internal/github" "github.com/ytnobody/hermit/internal/mcp" + "github.com/ytnobody/hermit/internal/notification" "github.com/ytnobody/hermit/internal/permissions" "github.com/ytnobody/hermit/internal/readiness" "github.com/ytnobody/hermit/internal/requirements" "github.com/ytnobody/hermit/internal/risk" + "github.com/ytnobody/hermit/internal/runloop" ) //go:embed templates/* templates/commands/* @@ -173,6 +179,17 @@ type Config struct { // "allow everyone" fallback; it resolves to this same safe default. TrustedAuthorAssociations []string `toml:"trusted_author_associations"` } `toml:"security"` + // Run configures `hermit run` (Issue #181), the long-lived process that + // owns the Superintendent tick loop outside of any Claude Code session. + Run struct { + // FailureNotifyThreshold is the number of consecutive failed passes + // after which `hermit run` sends a webhook notification (using + // [notification]). <= 0 falls back to defaultFailureNotifyThreshold, + // matching this codebase's existing "<=0 means default" convention + // (see LoopInterval/MaxEngineers in loadConfig) rather than treating + // 0 as "disabled". + FailureNotifyThreshold int `toml:"failure_notify_threshold"` + } `toml:"run"` } // resolveRiskConfig builds the effective default risk.Config (harness.toml's @@ -242,6 +259,8 @@ func main() { switch os.Args[1] { case "serve": cmdServe() + case "run": + cmdRun() case "install": cmdInstall() case "init": @@ -282,7 +301,7 @@ func main() { } func usage() { - fmt.Fprintln(os.Stderr, "Usage: hermit ") + fmt.Fprintln(os.Stderr, "Usage: hermit ") } const pauseFile = ".hermit-paused" @@ -525,6 +544,101 @@ func cmdServe() { } } +// defaultFailureNotifyThreshold is the fallback [run].failure_notify_threshold +// value applied when harness.toml omits it or sets it to a non-positive +// number (see Config.Run's doc comment). +const defaultFailureNotifyThreshold = 3 + +// claudeMdFile is the prompt file `hermit run` feeds to `claude -p` on every +// tick, read fresh each time so edits to CLAUDE.md take effect on the very +// next tick without restarting `hermit run`. +const claudeMdFile = "CLAUDE.md" + +// buildClaudeRunArgs builds the argument list for the non-interactive +// `claude` invocation `hermit run` performs on every tick. It mirrors the +// pattern documented in docs/github-actions.md for driving Claude Code +// unattended: `--dangerously-skip-permissions` so a tick never blocks on a +// permission prompt (Issue #181's "非対話で完走すること — permission prompt で +// ハングしない"), `--model` when a Superintendent model is configured, and +// `-p ` with the full CLAUDE.md contents as the prompt. +func buildClaudeRunArgs(superintendentModel, promptBody string) []string { + args := []string{"--dangerously-skip-permissions"} + if superintendentModel != "" { + args = append(args, "--model", superintendentModel) + } + args = append(args, "-p", promptBody) + return args +} + +// newClaudeInvoker returns a runloop.Invoker that runs `claude` once, +// non-interactively, using dir as both the working directory and the +// location CLAUDE.md is read from (so `hermit run` always reflects the +// current CLAUDE.md, even if it was edited between ticks). +func newClaudeInvoker(claudeBin, superintendentModel string) runloop.Invoker { + return func(ctx context.Context, dir string) error { + promptBody, err := os.ReadFile(filepath.Join(dir, claudeMdFile)) + if err != nil { + return fmt.Errorf("reading %s: %w", claudeMdFile, err) + } + args := buildClaudeRunArgs(superintendentModel, string(promptBody)) + cmd := exec.CommandContext(ctx, claudeBin, args...) + cmd.Dir = dir + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Stdin = nil + return cmd.Run() + } +} + +// cmdRun implements `hermit run` (Issue #181): a long-lived process, external +// to any Claude Code session, that ticks the Superintendent cycle by +// launching `claude -p` once per tick and waiting for it to finish. This +// replaces the requirement that a human keep a `claude` session with /hermit +// open indefinitely — see runloop.Run's doc comment for the tick/overlap/ +// graceful-shutdown semantics, and internal/state for the +// .hermit/superintendent-state.json file this owns. +// +// `hermit run` and the CLAUDE.md-driven `/hermit` slash command are not +// mutually exclusive: a project may keep using `/hermit` interactively while +// also running `hermit run` unattended (or vice versa) — see Issue #181's +// explicit "スコープ外" on retiring the slash command. +func cmdRun() { + cfg := loadConfig() + rootDir, err := os.Getwd() + if err != nil { + fatal(err.Error()) + } + if _, err := os.Stat(filepath.Join(rootDir, claudeMdFile)); err != nil { + fatal(fmt.Sprintf("%s not found in %s; run `hermit init` first", claudeMdFile, rootDir)) + } + + threshold := cfg.Run.FailureNotifyThreshold + if threshold <= 0 { + threshold = defaultFailureNotifyThreshold + } + interval := time.Duration(cfg.Agent.LoopInterval) * time.Second + + fmt.Printf("hermit run: starting (interval: %s, failure_notify_threshold: %d)\n", interval, threshold) + fmt.Println("hermit run: press Ctrl-C (SIGINT) or send SIGTERM to stop gracefully after the current pass") + + shutdownCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + opts := runloop.Options{ + RootDir: rootDir, + Invoke: newClaudeInvoker("claude", cfg.Model.Superintendent), + Interval: interval, + FailureNotifyThreshold: threshold, + WebhookURL: cfg.Notification.WebhookURL, + WebhookType: cfg.Notification.Type, + Notify: notification.Send, + } + if err := runloop.Run(shutdownCtx, opts); err != nil { + fatal(err.Error()) + } + fmt.Println("hermit run: stopped") +} + // resolveHearingPaths returns the effective list of candidate // requirements-document paths used by runRequirementsHearingCheck. // diff --git a/cmd/hermit/run_test.go b/cmd/hermit/run_test.go new file mode 100644 index 0000000..23b1c3d --- /dev/null +++ b/cmd/hermit/run_test.go @@ -0,0 +1,230 @@ +package main + +// Tests for `hermit run` (Issue #181): the long-lived process that ticks the +// Superintendent cycle by launching `claude -p` on an interval, outside of +// any Claude Code session. TestREQ019_* functions verify REQUIREMENTS.md's +// REQ-019 acceptance criteria; the plain-named tests exercise supporting +// helpers not directly named by the REQ-019 acceptance criteria. + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/BurntSushi/toml" +) + +// TestREQ019_UsageMentionsRun verifies `hermit run` is documented in the +// top-level usage/help text, per REQ-019's "hermit --help / usage に記載される". +func TestREQ019_UsageMentionsRun(t *testing.T) { + r, w, _ := os.Pipe() + orig := os.Stderr + os.Stderr = w + usage() + w.Close() + os.Stderr = orig + + buf := make([]byte, 4096) + n, _ := r.Read(buf) + out := string(buf[:n]) + if !strings.Contains(out, "run") { + t.Errorf("usage() output does not mention the run subcommand: %q", out) + } +} + +// TestREQ019_MainSwitchDispatchesRun verifies "run" is wired into main's +// subcommand dispatch (not just usage text). +func TestREQ019_MainSwitchDispatchesRun(t *testing.T) { + found := false + for _, name := range []string{"serve", "run", "install", "init", "pause", "resume", "quit", "status", "use", "version", "upgrade", "cleanup", "doctor", "dry-run"} { + if name == "run" { + found = true + } + } + if !found { + t.Fatal("sanity check failed: 'run' missing from the expected subcommand list") + } + // The authoritative check: cmdRun must exist and be callable (compile-time + // guarantee) — see TestCmdRunFatal_ClaudeMdMissing below for a behavioral + // exercise of the actual switch-case-invoked function. + var _ = cmdRun +} + +// TestREQ019_BuildClaudeRunArgsNonInteractive verifies the constructed +// `claude` invocation matches the non-interactive pattern documented in +// docs/github-actions.md: --dangerously-skip-permissions is always present +// (REQ-019's "非対話で完走すること — permission prompt でハングしない"), --model is +// included only when a Superintendent model is configured, and the prompt is +// passed via -p. +func TestREQ019_BuildClaudeRunArgsNonInteractive(t *testing.T) { + args := buildClaudeRunArgs("claude-sonnet-5", "prompt body") + joined := strings.Join(args, "\x00") + if !strings.Contains(joined, "--dangerously-skip-permissions") { + t.Errorf("args missing --dangerously-skip-permissions: %v", args) + } + if !strings.Contains(joined, "--model\x00claude-sonnet-5") { + t.Errorf("args missing --model claude-sonnet-5: %v", args) + } + if !strings.Contains(joined, "-p\x00prompt body") { + t.Errorf("args missing -p 'prompt body': %v", args) + } + + argsNoModel := buildClaudeRunArgs("", "prompt body") + for _, a := range argsNoModel { + if a == "--model" { + t.Errorf("args should omit --model when no Superintendent model is configured: %v", argsNoModel) + } + } +} + +// TestREQ019_NewClaudeInvokerRunsClaudeInProjectRoot verifies each tick's +// `claude` invocation uses the project root as its working directory and +// reads CLAUDE.md content fresh from that directory as the prompt — REQ-019's +// "各tickはプロジェクトルートをcwdとして claude -p を起動する". +func TestREQ019_NewClaudeInvokerRunsClaudeInProjectRoot(t *testing.T) { + dir := t.TempDir() + claudeMd := "the superintendent prompt" + if err := os.WriteFile(filepath.Join(dir, "CLAUDE.md"), []byte(claudeMd), 0o644); err != nil { + t.Fatalf("write CLAUDE.md: %v", err) + } + + // Fake `claude` binary that records its cwd and args to a file. + fakeBinDir := t.TempDir() + recordPath := filepath.Join(dir, "invocation.txt") + script := "#!/bin/sh\npwd > \"" + recordPath + "\"\nprintf '%s\\n' \"$@\" >> \"" + recordPath + "\"\nexit 0\n" + if err := os.WriteFile(filepath.Join(fakeBinDir, "claude"), []byte(script), 0o755); err != nil { + t.Fatalf("write fake claude: %v", err) + } + t.Setenv("PATH", fakeBinDir+":"+os.Getenv("PATH")) + + invoke := newClaudeInvoker("claude", "claude-sonnet-5") + if err := invoke(context.Background(), dir); err != nil { + t.Fatalf("invoke: %v", err) + } + + recorded, err := os.ReadFile(recordPath) + if err != nil { + t.Fatalf("reading invocation record: %v", err) + } + got := string(recorded) + + resolvedDir, _ := filepath.EvalSymlinks(dir) + resolvedGot, _ := filepath.EvalSymlinks(strings.TrimSpace(strings.SplitN(got, "\n", 2)[0])) + if resolvedGot != resolvedDir { + t.Errorf("claude ran with cwd %q, want %q", resolvedGot, resolvedDir) + } + if !strings.Contains(got, claudeMd) { + t.Errorf("claude was not invoked with CLAUDE.md's contents as the prompt: %q", got) + } + if !strings.Contains(got, "--dangerously-skip-permissions") { + t.Errorf("claude was not invoked non-interactively: %q", got) + } +} + +// TestCmdRunFatal_ClaudeMdMissing verifies cmdRun fails fast (before ever +// starting the tick loop) when CLAUDE.md is missing from the project root, +// instead of looping forever invoking a `claude -p` that would immediately +// error on every tick. +func TestCmdRunFatal_ClaudeMdMissing(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "harness.toml"), []byte(minimalHarnessTOML), 0o644); err != nil { + t.Fatalf("write harness.toml: %v", err) + } + prev, _ := os.Getwd() + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + defer os.Chdir(prev) + + msg := catchFatal(t, func() { cmdRun() }) + if !strings.Contains(msg, "CLAUDE.md") { + t.Errorf("expected a CLAUDE.md fatal message, got: %q", msg) + } +} + +// TestConfigRunSectionParsing verifies harness.toml's [run] section decodes +// into Config.Run.FailureNotifyThreshold. +func TestConfigRunSectionParsing(t *testing.T) { + src := minimalHarnessTOML + "\n[run]\nfailure_notify_threshold = 5\n" + var cfg Config + if _, err := toml.Decode(src, &cfg); err != nil { + t.Fatalf("decoding harness.toml: %v", err) + } + if cfg.Run.FailureNotifyThreshold != 5 { + t.Errorf("Run.FailureNotifyThreshold = %d, want 5", cfg.Run.FailureNotifyThreshold) + } +} + +// TestConfigRunSectionDefaultsToZeroWhenAbsent verifies an unconfigured +// [run] section decodes to the zero value, which cmdRun then maps to +// defaultFailureNotifyThreshold (mirroring how LoopInterval/MaxEngineers +// handle "<=0 means default" elsewhere in loadConfig). +func TestConfigRunSectionDefaultsToZeroWhenAbsent(t *testing.T) { + var cfg Config + if _, err := toml.Decode(minimalHarnessTOML, &cfg); err != nil { + t.Fatalf("decoding harness.toml: %v", err) + } + if cfg.Run.FailureNotifyThreshold != 0 { + t.Errorf("Run.FailureNotifyThreshold = %d, want 0 (unset) before cmdRun applies its default", cfg.Run.FailureNotifyThreshold) + } +} + +// TestNewClaudeInvokerPropagatesClaudeFailure verifies a non-zero `claude` +// exit code surfaces as an error from the Invoker, so runloop.Run's +// failure-counting/notification logic actually engages on a broken tick. +func TestNewClaudeInvokerPropagatesClaudeFailure(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "CLAUDE.md"), []byte("prompt"), 0o644); err != nil { + t.Fatalf("write CLAUDE.md: %v", err) + } + + fakeBinDir := t.TempDir() + script := "#!/bin/sh\nexit 1\n" + if err := os.WriteFile(filepath.Join(fakeBinDir, "claude"), []byte(script), 0o755); err != nil { + t.Fatalf("write fake claude: %v", err) + } + t.Setenv("PATH", fakeBinDir+":"+os.Getenv("PATH")) + + invoke := newClaudeInvoker("claude", "") + if err := invoke(context.Background(), dir); err == nil { + t.Error("expected an error when the claude invocation exits non-zero") + } +} + +// Sanity: exec.Command / exec.CommandContext usage in newClaudeInvoker must +// not require a shell (no shell metacharacter interpretation of the prompt +// body), otherwise a CLAUDE.md containing shell-special characters (quotes, +// backticks, $()) could corrupt or fail the invocation. This is implicit in +// using exec.CommandContext with a []string arg list (never exec.Command via +// "sh -c"), verified indirectly by TestREQ019_NewClaudeInvokerRunsClaudeInProjectRoot +// above using a CLAUDE.md body without special characters; this test uses one +// that does. +func TestNewClaudeInvokerHandlesShellSpecialCharsInPrompt(t *testing.T) { + dir := t.TempDir() + claudeMd := "prompt with `backticks`, $(command), and \"quotes\"" + if err := os.WriteFile(filepath.Join(dir, "CLAUDE.md"), []byte(claudeMd), 0o644); err != nil { + t.Fatalf("write CLAUDE.md: %v", err) + } + + fakeBinDir := t.TempDir() + recordPath := filepath.Join(dir, "invocation.txt") + script := "#!/bin/sh\nprintf '%s' \"$3\" > \"" + recordPath + "\"\nexit 0\n" + if err := os.WriteFile(filepath.Join(fakeBinDir, "claude"), []byte(script), 0o755); err != nil { + t.Fatalf("write fake claude: %v", err) + } + t.Setenv("PATH", fakeBinDir+":"+os.Getenv("PATH")) + + invoke := newClaudeInvoker("claude", "") + if err := invoke(context.Background(), dir); err != nil { + t.Fatalf("invoke: %v", err) + } + got, err := os.ReadFile(recordPath) + if err != nil { + t.Fatalf("reading invocation record: %v", err) + } + if string(got) != claudeMd { + t.Errorf("prompt arg = %q, want %q (shell metacharacters must reach claude literally, unexpanded)", string(got), claudeMd) + } +} diff --git a/cmd/hermit/serve_test.go b/cmd/hermit/serve_test.go index 2c5055e..6e67c26 100644 --- a/cmd/hermit/serve_test.go +++ b/cmd/hermit/serve_test.go @@ -103,8 +103,8 @@ language = "ja" continue } tools, _ := msg.Result["tools"].([]any) - if len(tools) != 18 { - t.Errorf("expected 18 tools, got %d", len(tools)) + if len(tools) != 20 { + t.Errorf("expected 20 tools, got %d", len(tools)) } return case <-deadline: @@ -207,8 +207,8 @@ language = "ja" continue } tools, _ := msg.Result["tools"].([]any) - if len(tools) != 18 { - t.Errorf("expected 18 tools, got %d", len(tools)) + if len(tools) != 20 { + t.Errorf("expected 20 tools, got %d", len(tools)) } return case <-deadline: @@ -314,8 +314,8 @@ language = "ja" continue } tools, _ := msg.Result["tools"].([]any) - if len(tools) != 18 { - t.Errorf("expected 18 tools, got %d", len(tools)) + if len(tools) != 20 { + t.Errorf("expected 20 tools, got %d", len(tools)) } return case <-deadline: diff --git a/cmd/hermit/templates/harness.toml.tmpl b/cmd/hermit/templates/harness.toml.tmpl index f5694dd..9b3b5e3 100644 --- a/cmd/hermit/templates/harness.toml.tmpl +++ b/cmd/hermit/templates/harness.toml.tmpl @@ -76,3 +76,6 @@ analyst_effort = "{{ .AnalystEffort }}" # reasoning effort: low/medium/ # # There is no config knob to disable the check; delete this comment block's # # intent by simply keeping a requirements doc at one of the default paths. # paths = ["REQUIREMENTS.md", "docs/requirements.md"] # any one of these existing is OK (default shown) + +# [run] +# failure_notify_threshold = 3 # `hermit run`: consecutive failed passes before a [notification] webhook fires (default: 3) diff --git a/internal/mcp/req_test.go b/internal/mcp/req_test.go index 88ba5dc..771408f 100644 --- a/internal/mcp/req_test.go +++ b/internal/mcp/req_test.go @@ -526,3 +526,103 @@ func TestREQ008_MergePR_WorktreeCleanup(t *testing.T) { // errMergeFailed is a sentinel merge error for TestREQ008_MergePR_WorktreeCleanup. var errMergeFailed = errors.New("merge failed (test)") + +// TestREQ019_GetLoopState_EmptyWhenNoStateFile verifies get_loop_state +// succeeds and reports nothing recorded yet when +// .hermit/superintendent-state.json does not exist — the state a fresh +// project starts in before `hermit run` or update_loop_state have ever +// touched it. +func TestREQ019_GetLoopState_EmptyWhenNoStateFile(t *testing.T) { + s, _ := newTestServerWithRoot(t, &mockGithubClient{}) + + result := callTool(t, s, "get_loop_state", map[string]any{}) + if result.IsError { + t.Fatalf("expected success, got error: %v", result.Content) + } + var resp map[string]any + decodeToolResult(t, result, &resp) + for _, key := range []string{"pr_comments_since", "issue_comments_since", "requirements_sweep_since", "last_success_tick"} { + if _, ok := resp[key]; ok { + t.Errorf("expected %q to be absent on a fresh project, got %v", key, resp[key]) + } + } + if cf, _ := resp["consecutive_failures"].(float64); cf != 0 { + t.Errorf("consecutive_failures = %v, want 0", resp["consecutive_failures"]) + } +} + +// TestREQ019_UpdateLoopState_PersistsViaMCPTool verifies the core of Issue +// #181's ".hermit/superintendent-state.json をGo側が所有する。3つのタイムスタンプの +// 読み書きはMCPツール経由で行う" requirement: update_loop_state writes the three +// cadence timestamps to the Go-owned state file, get_loop_state reads them +// back, and a partial update leaves previously-set fields untouched. +func TestREQ019_UpdateLoopState_PersistsViaMCPTool(t *testing.T) { + s, root := newTestServerWithRoot(t, &mockGithubClient{}) + + result := callTool(t, s, "update_loop_state", map[string]any{ + "pr_comments_since": "2026-07-28T10:00:00Z", + "issue_comments_since": "2026-07-28T11:00:00Z", + }) + if result.IsError { + t.Fatalf("update_loop_state: expected success, got error: %v", result.Content) + } + + // The file must actually exist on disk under .hermit/, owned by Go, not + // hand-written by the calling session. + if _, err := os.Stat(filepath.Join(root, ".hermit", "superintendent-state.json")); err != nil { + t.Fatalf(".hermit/superintendent-state.json not created by update_loop_state: %v", err) + } + + // Partial update: only requirements_sweep_since is set this time; the + // two fields set above must survive untouched. + result = callTool(t, s, "update_loop_state", map[string]any{ + "requirements_sweep_since": "2026-07-28T12:00:00Z", + }) + if result.IsError { + t.Fatalf("update_loop_state (partial): expected success, got error: %v", result.Content) + } + + result = callTool(t, s, "get_loop_state", map[string]any{}) + if result.IsError { + t.Fatalf("get_loop_state: expected success, got error: %v", result.Content) + } + var resp map[string]any + decodeToolResult(t, result, &resp) + if resp["pr_comments_since"] != "2026-07-28T10:00:00Z" { + t.Errorf("pr_comments_since = %v, want 2026-07-28T10:00:00Z", resp["pr_comments_since"]) + } + if resp["issue_comments_since"] != "2026-07-28T11:00:00Z" { + t.Errorf("issue_comments_since = %v, want 2026-07-28T11:00:00Z", resp["issue_comments_since"]) + } + if resp["requirements_sweep_since"] != "2026-07-28T12:00:00Z" { + t.Errorf("requirements_sweep_since = %v, want 2026-07-28T12:00:00Z", resp["requirements_sweep_since"]) + } +} + +// TestREQ019_UpdateLoopState_RejectsInvalidTimestamp verifies update_loop_state +// validates its RFC3339 inputs rather than silently persisting garbage. +func TestREQ019_UpdateLoopState_RejectsInvalidTimestamp(t *testing.T) { + s, _ := newTestServerWithRoot(t, &mockGithubClient{}) + + result := callTool(t, s, "update_loop_state", map[string]any{ + "pr_comments_since": "not-a-timestamp", + }) + if !result.IsError { + t.Fatalf("expected an error result for an invalid timestamp, got %v", result.Content) + } +} + +// decodeToolResult decodes the JSON text content of a tool result into out. +func decodeToolResult(t *testing.T, result *mcp.CallToolResult, out any) { + t.Helper() + if len(result.Content) == 0 { + t.Fatalf("tool result has no content") + } + tc, ok := result.Content[0].(mcp.TextContent) + if !ok { + t.Fatalf("tool result content is not text: %#v", result.Content[0]) + } + if err := json.Unmarshal([]byte(tc.Text), out); err != nil { + t.Fatalf("decoding tool result: %v", err) + } +} diff --git a/internal/mcp/tools.go b/internal/mcp/tools.go index 8749e3b..3298f72 100644 --- a/internal/mcp/tools.go +++ b/internal/mcp/tools.go @@ -17,6 +17,7 @@ import ( "github.com/ytnobody/hermit/internal/readiness" "github.com/ytnobody/hermit/internal/requirements" "github.com/ytnobody/hermit/internal/risk" + "github.com/ytnobody/hermit/internal/state" ) // clarificationTrigger is the marker HERMIT looks for in Issue/PR comments to @@ -551,6 +552,62 @@ func registerTools(s *server.MCPServer, client githubClient, rateLimitThreshold }, ) + s.AddTool( + mcp.NewTool("get_loop_state", + mcp.WithDescription("Returns the cadence-tracking timestamps persisted in .hermit/superintendent-state.json: pr_comments_since, issue_comments_since, and requirements_sweep_since (RFC3339, omitted if never recorded) — the 'since' values the Superintendent cycle uses to decide when it last checked PR comments, checked Issue comments, and ran the requirements sweep. Also reports last_success_tick and consecutive_failures, written by `hermit run`'s own tick loop. This file is owned by HERMIT's Go side: read/write the three cadence timestamps only via this tool and update_loop_state, never by hand-writing the JSON file."), + ), + func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + st, err := state.Load(state.Path(rootDir)) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + b, _ := json.Marshal(loopStateResponse(st)) + return mcp.NewToolResultText(string(b)), nil + }, + ) + + s.AddTool( + mcp.NewTool("update_loop_state", + mcp.WithDescription("Updates one or more of the cadence-tracking timestamps in .hermit/superintendent-state.json: pr_comments_since, issue_comments_since, requirements_sweep_since, each an RFC3339 timestamp. Only the fields provided are changed; omitted fields are left as-is. Call the now tool first to get an authoritative current timestamp to pass in, then use this instead of writing the JSON file directly. Returns the full updated state."), + mcp.WithString("pr_comments_since", mcp.Description("RFC3339 timestamp to record as the last PR-review-comment check time")), + mcp.WithString("issue_comments_since", mcp.Description("RFC3339 timestamp to record as the last Issue-comment check time")), + mcp.WithString("requirements_sweep_since", mcp.Description("RFC3339 timestamp to record as the last requirements-sweep time")), + ), + func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + statePath := state.Path(rootDir) + st, err := state.Load(statePath) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + if v := req.GetString("pr_comments_since", ""); v != "" { + t, err := time.Parse(time.RFC3339, v) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("pr_comments_since: %v", err)), nil + } + st.PRCommentsSince = &t + } + if v := req.GetString("issue_comments_since", ""); v != "" { + t, err := time.Parse(time.RFC3339, v) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("issue_comments_since: %v", err)), nil + } + st.IssueCommentsSince = &t + } + if v := req.GetString("requirements_sweep_since", ""); v != "" { + t, err := time.Parse(time.RFC3339, v) + if err != nil { + return mcp.NewToolResultError(fmt.Sprintf("requirements_sweep_since: %v", err)), nil + } + st.RequirementsSweepSince = &t + } + if err := state.Save(statePath, st); err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + b, _ := json.Marshal(loopStateResponse(st)) + return mcp.NewToolResultText(string(b)), nil + }, + ) + s.AddTool( mcp.NewTool("review_pr", mcp.WithDescription("Posts a structured automated review comment on a PR based on static analysis of the diff"), @@ -701,3 +758,23 @@ func registerTools(s *server.MCPServer, client githubClient, rateLimitThreshold }, ) } + +// loopStateResponse converts a state.LoopState into the JSON-friendly shape +// returned by get_loop_state / update_loop_state: each timestamp is an +// RFC3339 string when set, or omitted entirely when nil, so callers can +// treat a missing key the same way as "never recorded". +func loopStateResponse(st state.LoopState) map[string]any { + resp := map[string]any{ + "consecutive_failures": st.ConsecutiveFailures, + } + setIfNotNil := func(key string, t *time.Time) { + if t != nil { + resp[key] = t.UTC().Format(time.RFC3339) + } + } + setIfNotNil("pr_comments_since", st.PRCommentsSince) + setIfNotNil("issue_comments_since", st.IssueCommentsSince) + setIfNotNil("requirements_sweep_since", st.RequirementsSweepSince) + setIfNotNil("last_success_tick", st.LastSuccessTick) + return resp +} diff --git a/internal/runloop/runloop.go b/internal/runloop/runloop.go new file mode 100644 index 0000000..c6f53de --- /dev/null +++ b/internal/runloop/runloop.go @@ -0,0 +1,210 @@ +// Package runloop implements the ticker that drives `hermit run` (Issue +// #181): a long-lived process, external to any Claude Code session, that +// invokes one Superintendent pass at a time and waits [agent].loop_interval +// after each pass completes before starting the next one. +// +// This intentionally replaces the Claude-Code-session-hosted `/loop` +// mechanism (previously driven by CLAUDE.md's Superintendent cycle) as the +// thing that keeps ticking. It does NOT reintroduce a background-subagent +// loop inside a Claude Code session (issue #147's design, which exhausted +// the session's subagent-spawn cap per issue #171) — there is no subagent +// spawn here at all; each tick is a plain child-process invocation +// (`claude -p ...`) of a Go long-lived process, the same shape `hermit +// serve` already uses for the MCP server. +package runloop + +import ( + "context" + "fmt" + "log" + "os" + "path/filepath" + "time" + + "github.com/ytnobody/hermit/internal/state" +) + +// Invoker runs one Superintendent pass to completion, using dir as the +// working directory, and returns any error. ctx passed to Invoke is always +// context.Background()-derived from Run's perspective, never the shutdown +// context — see Run's doc comment for why a pass is never interrupted. +type Invoker func(ctx context.Context, dir string) error + +// NotifyFunc sends a notification (e.g. internal/notification.Send). +type NotifyFunc func(webhookURL, webhookType, event, message string) error + +// Options configures Run. +type Options struct { + // RootDir is the project root: both the working directory passed to + // Invoke and the directory containing .hermit/superintendent-state.json. + RootDir string + + // Invoke runs one Superintendent pass. Required. + Invoke Invoker + + // Interval is how long Run waits after a pass finishes before starting + // the next one (harness.toml's [agent].loop_interval). + Interval time.Duration + + // FailureNotifyThreshold is the number of consecutive failed passes + // after which Run sends one notification via Notify. <= 0 disables + // failure notification. + FailureNotifyThreshold int + + // WebhookURL / WebhookType are passed through to Notify. + WebhookURL string + WebhookType string + + // Notify sends the failure notification. Required only when + // FailureNotifyThreshold > 0; if nil in that case, Run logs a warning + // and skips notifying rather than panicking. + Notify NotifyFunc + + // Now returns the current time; defaults to time.Now when nil. + Now func() time.Time + + // Logger receives progress/diagnostic output; defaults to log.Default() + // when nil. + Logger *log.Logger +} + +// pauseFileName / quitFileName mirror cmd/hermit's own pauseFile/quitFile +// constants (".hermit-paused" / ".hermit-quit"). Duplicated here (rather +// than imported) to keep this package free of an import-cycle risk back +// into cmd/hermit; both sides are simple constants unlikely to drift. +const ( + pauseFileName = ".hermit-paused" + quitFileName = ".hermit-quit" +) + +// sleeper abstracts waiting for Interval so tests don't need real time. +// It returns true if the wait was cut short by shutdownCtx being canceled. +type sleeper func(shutdownCtx context.Context, d time.Duration) (canceled bool) + +func defaultSleeper(shutdownCtx context.Context, d time.Duration) bool { + if d <= 0 { + select { + case <-shutdownCtx.Done(): + return true + default: + return false + } + } + t := time.NewTimer(d) + defer t.Stop() + select { + case <-shutdownCtx.Done(): + return true + case <-t.C: + return false + } +} + +// Run drives the tick loop until shutdownCtx is canceled (SIGINT/SIGTERM in +// `hermit run`'s case) or .hermit-quit is detected in RootDir. +// +// Overlap safety: each iteration blocks on opts.Invoke before doing +// anything else in the loop body, so two passes running at once is +// structurally impossible within one Run call — there is no separate +// goroutine or timer that could fire a second Invoke while the first is +// still in flight. +// +// Graceful shutdown: shutdownCtx is only ever consulted BETWEEN passes — +// before starting the next Invoke call, and while waiting out Interval — and +// is deliberately never wired into the context passed to Invoke itself +// (Invoke always receives a fresh context.Background()-derived context). +// This means SIGINT/SIGTERM never aborts a Superintendent pass that has +// already started; Run finishes the current pass, records its result, and +// then stops instead of starting another one. +func Run(shutdownCtx context.Context, opts Options) error { + return run(shutdownCtx, opts, defaultSleeper) +} + +func run(shutdownCtx context.Context, opts Options, sleep sleeper) error { + if opts.Invoke == nil { + return fmt.Errorf("runloop: Options.Invoke is required") + } + now := opts.Now + if now == nil { + now = time.Now + } + logger := opts.Logger + if logger == nil { + logger = log.Default() + } + statePath := state.Path(opts.RootDir) + pausePath := filepath.Join(opts.RootDir, pauseFileName) + quitPath := filepath.Join(opts.RootDir, quitFileName) + + for { + if shutdownRequested(shutdownCtx) { + logger.Println("hermit run: shutdown signal received, stopping") + return nil + } + if fileExists(quitPath) { + logger.Println("hermit run: .hermit-quit detected, stopping (not resumable; run `hermit run` again to restart)") + return nil + } + if fileExists(pausePath) { + logger.Println("hermit run: .hermit-paused detected, skipping this tick") + if sleep(shutdownCtx, opts.Interval) { + logger.Println("hermit run: shutdown signal received while paused, stopping") + return nil + } + continue + } + + logger.Println("hermit run: starting pass") + // Deliberately context.Background(), not shutdownCtx: see Run's doc + // comment on why an in-flight pass is never interrupted. + passErr := opts.Invoke(context.Background(), opts.RootDir) + + st, loadErr := state.Load(statePath) + if loadErr != nil { + logger.Printf("hermit run: warning: failed to load state: %v", loadErr) + } + n := now() + if passErr != nil { + st.ConsecutiveFailures++ + logger.Printf("hermit run: pass failed (%d consecutive failure(s)): %v", st.ConsecutiveFailures, passErr) + if opts.FailureNotifyThreshold > 0 && st.ConsecutiveFailures == opts.FailureNotifyThreshold { + msg := fmt.Sprintf("HERMIT: %d consecutive `hermit run` passes have failed. Last error: %v", st.ConsecutiveFailures, passErr) + if opts.Notify == nil { + logger.Println("hermit run: warning: failure threshold reached but no Notify function configured") + } else if err := opts.Notify(opts.WebhookURL, opts.WebhookType, "run_failure", msg); err != nil { + logger.Printf("hermit run: warning: failed to send failure notification: %v", err) + } + } + } else { + st.ConsecutiveFailures = 0 + st.LastSuccessTick = &n + logger.Println("hermit run: pass completed successfully") + } + if err := state.Save(statePath, st); err != nil { + logger.Printf("hermit run: warning: failed to save state: %v", err) + } + + if shutdownRequested(shutdownCtx) { + logger.Println("hermit run: shutdown signal received after pass, stopping") + return nil + } + if sleep(shutdownCtx, opts.Interval) { + logger.Println("hermit run: shutdown signal received while waiting, stopping") + return nil + } + } +} + +func shutdownRequested(ctx context.Context) bool { + select { + case <-ctx.Done(): + return true + default: + return false + } +} + +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} diff --git a/internal/runloop/runloop_test.go b/internal/runloop/runloop_test.go new file mode 100644 index 0000000..46b2377 --- /dev/null +++ b/internal/runloop/runloop_test.go @@ -0,0 +1,339 @@ +package runloop + +import ( + "context" + "errors" + "log" + "os" + "path/filepath" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/ytnobody/hermit/internal/state" +) + +func testLogger() *log.Logger { + return log.New(discardWriter{}, "", 0) +} + +type discardWriter struct{} + +func (discardWriter) Write(p []byte) (int, error) { return len(p), nil } + +func TestRun_RequiresInvoke(t *testing.T) { + err := Run(context.Background(), Options{RootDir: t.TempDir(), Logger: testLogger()}) + if err == nil { + t.Fatal("Run with nil Invoke: want error, got nil") + } +} + +// TestREQ019_NoOverlappingPasses verifies that Run never starts a new pass +// before the previous one's Invoke call has returned, even when a pass +// takes a while — Issue #181's "パスが長時間かかっても重複起動しない". +func TestREQ019_NoOverlappingPasses(t *testing.T) { + dir := t.TempDir() + + var ( + mu sync.Mutex + concurrent int + maxSeen int + invokeCalls int32 + ) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + invoke := func(ctx context.Context, d string) error { + mu.Lock() + concurrent++ + if concurrent > maxSeen { + maxSeen = concurrent + } + mu.Unlock() + + time.Sleep(10 * time.Millisecond) + + mu.Lock() + concurrent-- + mu.Unlock() + + if atomic.AddInt32(&invokeCalls, 1) >= 3 { + cancel() + } + return nil + } + + opts := Options{ + RootDir: dir, + Invoke: invoke, + Interval: time.Millisecond, + Logger: testLogger(), + } + if err := Run(ctx, opts); err != nil { + t.Fatalf("Run: %v", err) + } + + mu.Lock() + defer mu.Unlock() + if maxSeen > 1 { + t.Errorf("observed %d concurrent passes, want at most 1", maxSeen) + } + if invokeCalls < 3 { + t.Errorf("invokeCalls = %d, want at least 3", invokeCalls) + } +} + +// TestREQ019_GracefulShutdownDoesNotInterruptInFlightPass verifies that +// canceling the shutdown context while a pass is running lets that pass run +// to completion (its ctx is never canceled), and Run stops only after it +// finishes — Issue #181's "SIGINT/SIGTERM で実行中のパスを中断せずグレースフルに +// 停止する". +func TestREQ019_GracefulShutdownDoesNotInterruptInFlightPass(t *testing.T) { + dir := t.TempDir() + shutdownCtx, cancel := context.WithCancel(context.Background()) + + var completed atomic.Bool + started := make(chan struct{}) + + invoke := func(passCtx context.Context, d string) error { + close(started) + // Cancel the shutdown signal partway through this pass. + cancel() + select { + case <-passCtx.Done(): + t.Error("pass context was canceled; an in-flight pass must not be interrupted by shutdown") + case <-time.After(20 * time.Millisecond): + } + completed.Store(true) + return nil + } + + opts := Options{ + RootDir: dir, + Invoke: invoke, + Interval: time.Millisecond, + Logger: testLogger(), + } + + done := make(chan error, 1) + go func() { done <- Run(shutdownCtx, opts) }() + + <-started + select { + case err := <-done: + if err != nil { + t.Fatalf("Run: %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("Run did not stop after shutdown + one in-flight pass") + } + if !completed.Load() { + t.Error("pass did not complete before Run returned") + } +} + +// TestREQ019_QuitFileStopsLoop verifies .hermit-quit is detected by the +// run loop itself (not only by the invoked Claude session). +func TestREQ019_QuitFileStopsLoop(t *testing.T) { + dir := t.TempDir() + var calls int32 + + invoke := func(ctx context.Context, d string) error { + n := atomic.AddInt32(&calls, 1) + if n == 1 { + if err := os.WriteFile(filepath.Join(dir, ".hermit-quit"), nil, 0o644); err != nil { + t.Fatalf("write quit file: %v", err) + } + } + return nil + } + + opts := Options{ + RootDir: dir, + Invoke: invoke, + Interval: time.Millisecond, + Logger: testLogger(), + } + done := make(chan error, 1) + go func() { done <- Run(context.Background(), opts) }() + + select { + case err := <-done: + if err != nil { + t.Fatalf("Run: %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("Run did not stop after .hermit-quit was created") + } + if calls != 1 { + t.Errorf("invoke called %d times, want exactly 1 (loop should stop before a 2nd pass once quit is detected)", calls) + } +} + +// TestREQ019_PauseFileSkipsPassesWithoutStoppingLoop verifies .hermit-paused +// is detected by the run loop itself, and that it skips Invoke rather than +// stopping the loop entirely. +func TestREQ019_PauseFileSkipsPassesWithoutStoppingLoop(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, ".hermit-paused"), nil, 0o644); err != nil { + t.Fatalf("write pause file: %v", err) + } + + var calls int32 + invoke := func(ctx context.Context, d string) error { + atomic.AddInt32(&calls, 1) + return nil + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond) + defer cancel() + + opts := Options{ + RootDir: dir, + Invoke: invoke, + Interval: 2 * time.Millisecond, + Logger: testLogger(), + } + if err := Run(ctx, opts); err != nil { + t.Fatalf("Run: %v", err) + } + if calls != 0 { + t.Errorf("invoke called %d times while paused, want 0", calls) + } +} + +// TestREQ019_RecordsSuccessAndResetsFailures verifies a successful pass +// updates last_success_tick and resets consecutive_failures in +// .hermit/superintendent-state.json — the Go-owned state file. +func TestREQ019_RecordsSuccessAndResetsFailures(t *testing.T) { + dir := t.TempDir() + fixedNow := time.Date(2026, 7, 28, 12, 0, 0, 0, time.UTC) + + // Seed a prior failure count to verify it gets reset on success. + if err := state.Save(state.Path(dir), state.LoopState{ConsecutiveFailures: 2}); err != nil { + t.Fatalf("seed state: %v", err) + } + + var calls int32 + ctx, cancel := context.WithCancel(context.Background()) + invoke := func(ctx context.Context, d string) error { + if atomic.AddInt32(&calls, 1) >= 1 { + cancel() + } + return nil + } + + opts := Options{ + RootDir: dir, + Invoke: invoke, + Interval: time.Millisecond, + Now: func() time.Time { return fixedNow }, + Logger: testLogger(), + } + if err := Run(ctx, opts); err != nil { + t.Fatalf("Run: %v", err) + } + + st, err := state.Load(state.Path(dir)) + if err != nil { + t.Fatalf("Load: %v", err) + } + if st.ConsecutiveFailures != 0 { + t.Errorf("ConsecutiveFailures = %d, want 0 after success", st.ConsecutiveFailures) + } + if st.LastSuccessTick == nil || !st.LastSuccessTick.Equal(fixedNow) { + t.Errorf("LastSuccessTick = %v, want %v", st.LastSuccessTick, fixedNow) + } +} + +// TestREQ019_NotifiesAfterConsecutiveFailureThreshold verifies the +// liveness/notification requirement: after N consecutive failed passes, a +// notification fires via the configured webhook (N configurable). +func TestREQ019_NotifiesAfterConsecutiveFailureThreshold(t *testing.T) { + dir := t.TempDir() + + var notifyCalls int32 + var lastMsg string + notify := func(webhookURL, webhookType, event, message string) error { + atomic.AddInt32(¬ifyCalls, 1) + lastMsg = message + return nil + } + + ctx, cancel := context.WithCancel(context.Background()) + var calls int32 + invoke := func(ctx context.Context, d string) error { + n := atomic.AddInt32(&calls, 1) + if n >= 3 { + cancel() + } + return errors.New("boom") + } + + opts := Options{ + RootDir: dir, + Invoke: invoke, + Interval: time.Millisecond, + FailureNotifyThreshold: 2, + Notify: notify, + Logger: testLogger(), + } + if err := Run(ctx, opts); err != nil { + t.Fatalf("Run: %v", err) + } + + if notifyCalls != 1 { + t.Fatalf("notify called %d times, want exactly 1 (fires once when the threshold is first reached)", notifyCalls) + } + if lastMsg == "" { + t.Error("notification message was empty") + } + + st, err := state.Load(state.Path(dir)) + if err != nil { + t.Fatalf("Load: %v", err) + } + if st.ConsecutiveFailures < 2 { + t.Errorf("ConsecutiveFailures = %d, want >= 2", st.ConsecutiveFailures) + } +} + +// TestREQ019_ZeroThresholdDisablesNotification verifies +// FailureNotifyThreshold <= 0 never calls Notify, matching the codebase's +// existing "<=0 means use/disable default" convention (see loadConfig's +// LoopInterval/MaxEngineers handling in cmd/hermit). +func TestREQ019_ZeroThresholdDisablesNotification(t *testing.T) { + dir := t.TempDir() + var notifyCalls int32 + notify := func(webhookURL, webhookType, event, message string) error { + atomic.AddInt32(¬ifyCalls, 1) + return nil + } + + ctx, cancel := context.WithCancel(context.Background()) + var calls int32 + invoke := func(ctx context.Context, d string) error { + n := atomic.AddInt32(&calls, 1) + if n >= 5 { + cancel() + } + return errors.New("boom") + } + + opts := Options{ + RootDir: dir, + Invoke: invoke, + Interval: time.Millisecond, + FailureNotifyThreshold: 0, + Notify: notify, + Logger: testLogger(), + } + if err := Run(ctx, opts); err != nil { + t.Fatalf("Run: %v", err) + } + if notifyCalls != 0 { + t.Errorf("notify called %d times with threshold disabled, want 0", notifyCalls) + } +} diff --git a/internal/state/state.go b/internal/state/state.go new file mode 100644 index 0000000..595d55a --- /dev/null +++ b/internal/state/state.go @@ -0,0 +1,122 @@ +// Package state owns .hermit/superintendent-state.json, the persisted +// cadence/liveness state for HERMIT's autonomous loop (Issue #181). +// +// Two independent writers share this one file: +// +// - `hermit run` (internal/runloop), the long-lived process that owns the +// ticker, writes LastSuccessTick / ConsecutiveFailures directly after +// each pass. +// - The get_loop_state / update_loop_state MCP tools (internal/mcp), +// called from inside a Superintendent pass, own +// PRCommentsSince / IssueCommentsSince / RequirementsSweepSince — the +// three "since" timestamps the Superintendent cycle previously had to +// hand-write into the file itself. +// +// Either writer only ever does a load-modify-save round trip, and the two +// never run concurrently by construction: `hermit run` blocks on the +// Invoke call (which runs the Superintendent pass, including any +// update_loop_state calls) before it touches the file itself again. Save +// still writes atomically (temp file + rename) as cheap insurance against a +// half-written file if the process is killed mid-write. +package state + +import ( + "encoding/json" + "os" + "path/filepath" + "time" +) + +// DirName is the HERMIT state directory name, relative to the project root. +const DirName = ".hermit" + +// FileName is the state file name within DirName. +const FileName = "superintendent-state.json" + +// Dir returns the .hermit directory path under rootDir. +func Dir(rootDir string) string { + return filepath.Join(rootDir, DirName) +} + +// Path returns the full path to the state file under rootDir. +func Path(rootDir string) string { + return filepath.Join(Dir(rootDir), FileName) +} + +// LoopState is the JSON shape of .hermit/superintendent-state.json. All +// fields are optional (pointers/zero values) so a project that has never +// run any part of the loop yet round-trips a valid, empty state. +type LoopState struct { + // PRCommentsSince is the "since" timestamp for the last PR-review-comment + // check (get_recent_pr_comments), set via update_loop_state. + PRCommentsSince *time.Time `json:"pr_comments_since,omitempty"` + // IssueCommentsSince is the "since" timestamp for the last Issue-comment + // check (get_issue_comments), set via update_loop_state. + IssueCommentsSince *time.Time `json:"issue_comments_since,omitempty"` + // RequirementsSweepSince is the last time run_requirements_sweep ran, set + // via update_loop_state. + RequirementsSweepSince *time.Time `json:"requirements_sweep_since,omitempty"` + // LastSuccessTick is the wall-clock time of the most recent + // `hermit run` pass that completed without error. Written directly by + // internal/runloop, not via an MCP tool. + LastSuccessTick *time.Time `json:"last_success_tick,omitempty"` + // ConsecutiveFailures counts consecutive failed `hermit run` passes + // since the last success; reset to 0 on success. Written directly by + // internal/runloop. + ConsecutiveFailures int `json:"consecutive_failures,omitempty"` +} + +// Load reads the state file at path. A missing file is not an error: it +// returns the zero-value LoopState, matching a project where the loop has +// never recorded any state yet. +func Load(path string) (LoopState, error) { + var st LoopState + b, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return st, nil + } + return st, err + } + if len(b) == 0 { + return st, nil + } + if err := json.Unmarshal(b, &st); err != nil { + return st, err + } + return st, nil +} + +// Save atomically writes st to path, creating the parent directory (e.g. +// .hermit/) if it does not already exist. The write goes through a temp +// file in the same directory followed by a rename, so a process killed +// mid-write can never leave a truncated/corrupt state file behind. +func Save(path string, st LoopState) error { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + b, err := json.MarshalIndent(st, "", " ") + if err != nil { + return err + } + tmp, err := os.CreateTemp(dir, ".superintendent-state-*.tmp") + if err != nil { + return err + } + tmpName := tmp.Name() + if _, err := tmp.Write(b); err != nil { + tmp.Close() + os.Remove(tmpName) + return err + } + if err := tmp.Close(); err != nil { + os.Remove(tmpName) + return err + } + if err := os.Rename(tmpName, path); err != nil { + os.Remove(tmpName) + return err + } + return nil +} diff --git a/internal/state/state_test.go b/internal/state/state_test.go new file mode 100644 index 0000000..ff9df4f --- /dev/null +++ b/internal/state/state_test.go @@ -0,0 +1,111 @@ +package state + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestLoadMissingFileReturnsZeroValue(t *testing.T) { + dir := t.TempDir() + st, err := Load(Path(dir)) + if err != nil { + t.Fatalf("Load: unexpected error: %v", err) + } + if st.PRCommentsSince != nil || st.IssueCommentsSince != nil || st.RequirementsSweepSince != nil { + t.Fatalf("Load on missing file: want zero-value LoopState, got %+v", st) + } + if st.ConsecutiveFailures != 0 { + t.Fatalf("Load on missing file: want ConsecutiveFailures 0, got %d", st.ConsecutiveFailures) + } +} + +func TestSaveThenLoadRoundTrips(t *testing.T) { + dir := t.TempDir() + path := Path(dir) + + now := time.Now().UTC().Truncate(time.Second) + want := LoopState{ + PRCommentsSince: &now, + IssueCommentsSince: &now, + RequirementsSweepSince: &now, + LastSuccessTick: &now, + ConsecutiveFailures: 2, + } + if err := Save(path, want); err != nil { + t.Fatalf("Save: %v", err) + } + + got, err := Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + if got.PRCommentsSince == nil || !got.PRCommentsSince.Equal(now) { + t.Errorf("PRCommentsSince = %v, want %v", got.PRCommentsSince, now) + } + if got.IssueCommentsSince == nil || !got.IssueCommentsSince.Equal(now) { + t.Errorf("IssueCommentsSince = %v, want %v", got.IssueCommentsSince, now) + } + if got.RequirementsSweepSince == nil || !got.RequirementsSweepSince.Equal(now) { + t.Errorf("RequirementsSweepSince = %v, want %v", got.RequirementsSweepSince, now) + } + if got.LastSuccessTick == nil || !got.LastSuccessTick.Equal(now) { + t.Errorf("LastSuccessTick = %v, want %v", got.LastSuccessTick, now) + } + if got.ConsecutiveFailures != 2 { + t.Errorf("ConsecutiveFailures = %d, want 2", got.ConsecutiveFailures) + } +} + +func TestSaveCreatesHermitDir(t *testing.T) { + dir := t.TempDir() + if _, err := os.Stat(Dir(dir)); !os.IsNotExist(err) { + t.Fatalf(".hermit dir already exists before Save") + } + if err := Save(Path(dir), LoopState{}); err != nil { + t.Fatalf("Save: %v", err) + } + if _, err := os.Stat(Dir(dir)); err != nil { + t.Fatalf(".hermit dir not created by Save: %v", err) + } +} + +func TestSavePartialUpdatePreservesOtherFields(t *testing.T) { + dir := t.TempDir() + path := Path(dir) + + t1 := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + if err := Save(path, LoopState{PRCommentsSince: &t1}); err != nil { + t.Fatalf("Save: %v", err) + } + + st, err := Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + t2 := time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC) + st.IssueCommentsSince = &t2 + if err := Save(path, st); err != nil { + t.Fatalf("Save: %v", err) + } + + got, err := Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + if got.PRCommentsSince == nil || !got.PRCommentsSince.Equal(t1) { + t.Errorf("PRCommentsSince lost after partial update: got %v, want %v", got.PRCommentsSince, t1) + } + if got.IssueCommentsSince == nil || !got.IssueCommentsSince.Equal(t2) { + t.Errorf("IssueCommentsSince = %v, want %v", got.IssueCommentsSince, t2) + } +} + +func TestPathUnderHermitDir(t *testing.T) { + got := Path("/some/project") + want := filepath.Join("/some/project", ".hermit", "superintendent-state.json") + if got != want { + t.Errorf("Path = %q, want %q", got, want) + } +}