AI のセッションを、並行で回す
シリーズ・第 6 回 — 「AI エージェントのためのタスク管理をつくる」。前回は同時に書いても壊れない理由でした。今回は、その上で実際に開発をどう回しているかです。
第 1 回で、AI エージェントの手詰まりの一つに「複数に分ければタスクがぶつかる」を挙げました。今回はそこへの答え —— Amenbo(https://amenbo.work/)自身の開発で、実際にどう並行させているかを書きます。
普段は、AI のセッションを2〜3本、同時に走らせています。1本が終わるのを待たない。ただし、何も用意せずに並べると、すぐに壊れます。
ぶつかるのは、2か所
並行で困るのは、大きく2つです。
- ファイルがぶつかる。 セッション A が書きかけのファイルを、セッション B がビルドする。どちらの変更も中途半端に混ざり、テストの結果が何を指しているのか分からなくなる。
- タスクがぶつかる。 2本のセッションが、同じタスクに手をつける。同じものを2回つくって、あとでマージで揉める。
分けるものが2つあるので、対策も2つです。ファイルは git の worktree で、タスクは予約で分けます。
ファイルは、worktree で分ける
git の worktree は、1つのリポジトリから、別のフォルダへ、別のブランチを同時にチェックアウトする機能です。クローンし直すのとは違い、履歴は1つのまま共有されます。
これをタスク1件につき1つ用意します。タスク番号がそのままフォルダ名とブランチ名になります。
| もの | 場所・名前 |
|---|---|
| worktree | <リポジトリ>/../<リポジトリ名>-worktrees/<タスク番号> |
| ブランチ | task/<タスク番号> |
名前がタスク番号で決まるので、同じタスクを2回始めようとすると、同じパスとブランチ名で必ずぶつかって弾かれます。「うっかり二重に始めていた」が、名前の衝突として表に出る。
worktree は、リポジトリの外に置く
置き場所が親フォルダの隣なのは、意図的です。worktree の祖先に .amenbo が無いようにしています。
第 4 回で書いたとおり、フォルダの .amenbo が AI の活動範囲でした。それが無いということは、worktree の中から amenbo を打っても、本物のバックログには届かないということです。この性質を使って、2つの関心事を物理的に離しています。
- タスクの管理(予約・コメント・完了)は、メインのリポジトリから、本番の
amenboで。 - コードの検証(自分の変更が動くか)は、worktree の中で、使い捨てのストアを相手に。
開発中のビルドが、うっかり本物のバックログを触ることはありません。置き場所を分けただけで、そうなります。
.amenbo が無いので、開発中のビルドはバックログに届かないタスクは、予約で分ける
もう一方の衝突です。各セッションは、自分宛ての未着手タスク(assignee:me-ai status:todo ready:yes)から1件選び、予約してから始めます。予約はステータスを in_progress にすることです。
ここが肝で、この遷移は todo のときだけ成功する compare-and-swap になっています。すでに誰かが in_progress にしていたら、2本目の予約は already_reserved で断られ、コマンドは失敗して終わります。
todo からだけ。負けた側は成功せず、はっきり断られる大事なのは、負けた側が静かに素通りしないことです。ふつう、同じ状態に置き直す操作は「もうそうなっている」で成功扱いになりがちです。それだと2本目は自分が予約を取れたと思い込み、同じタスクを平気で作り始める。断られるからこそ、衝突がその場で分かり、次のタスクへ移れます。
始めるのは、コマンド1つ
ここまでの段取りは、devtool という小さな自作コマンドにまとめてあります(ソースは記事の最後に全部載せました)。devtool task start 696 の1回で、こうなります。
- タスク 696 を予約し、本当に
in_progressになったか確かめ直す。 - worktree とブランチ
task/696をつくる。 - GUI を含むなら
npm ciまで済ませる(失敗しても止まらない。警告だけ出す)。 - タスクの本文・notes・紐づいた決定・最新のコメントを、その場に流し込む。
- worktree へ入る
cdを出力する。
4つ目が、第 1 回に書いた狙いそのものです。着手の瞬間に「何を前提に、何が決まっていて、いま何で止まっているのか」が目の前に出る。探しにいく手間もいらないし、探さずに書き始めることもできない。
出力は eval できる形なので、セッションはこう始まります。
eval "$(devtool task start 696)" # 予約して、worktree に入る
「すでに worktree がある」の2つの意味
並行で回していると、これから始めようとしたタスクに worktree がもうある、という場面が出ます。ディスク上では、この2つがまったく同じに見えます。
| 状況 | 実際に起きていること | やること |
|---|---|---|
バックログで in_progress |
別のセッションが作業中 | 手を出さない。別のタスクを取る |
in_progress ではない |
自分の置き忘れ | 片付けてから始め直す |
なので、断るときにどちらなのかを名指しします。名指さないと、「たぶん古いやつだろう」と判断して消しにいくからです。他のセッションが作業中の worktree は、中を見ない・古いかどうか判断しない・消さない。この3つを、断り文にそのまま書いています。
片付けまでを含めて、1タスク
終わったら devtool task finish 696 で畳みます。ここも、条件を満たさないと断ります。
- worktree に未コミットの変更が残っている → 断る
- ブランチが
mainにマージされていない → 断る
どちらも --force で押し切れますが、既定は断る側です。通れば worktree とブランチを消し、他のタスクの worktree が残っていなければ、親フォルダごと片付けます。1タスク=1コミットなので、残るのはコミット1つだけです。
万能ではない
これで衝突が消えるわけではありません。同じところを触るタスクを並べれば、最後のマージで揉めます。 並べられる本数は、タスクがどれだけ独立しているかで決まる —— つまり、バックログの切り方の問題です。実際、大きめの機能を細かく割ったつもりでも、隣り合ったタスク同士がぶつかったことがあります。
それでも、ファイルとタスクという2つの衝突を先に潰しておくと、残るのは「マージが揉めた」という見える形の衝突だけになります。書きかけのファイルが混ざって、テストの緑が何を意味するのか分からなくなる —— あの手の見えない壊れ方は、無くなります。
devtool のソース
devtool は、Amenbo を自分が使い倒すためだけに書いた、内部用の小さなコマンドです。Go のファイル5つ、全部で 580 行ほど。ランタイムも仮想環境もいらない1バイナリなので、言語を問わずどのプロジェクトにも置けます。
go build するだけで動きます。全文を載せておきます(テストは省きました)。
Amenbo に依存しているのは amenbo.go の予約まわりだけで、残りは素の git 操作です。同じ考え方を別のタスク管理でやるなら、そこだけ差し替えれば足ります。
main.go — コマンドの本体。start は予約 → worktree → cd の出力まで、finish は片付けの拒否条件を持つ
// Command devtool is amenbo's portable developer-support CLI: a single static
// Go binary (no runtime, no venv) that can be dropped into any project. Today it
// stamps out and tears down per-task git worktrees so several implementation
// sessions can run in parallel without stepping on each other.
//
// A task's worktree lives OUTSIDE the repo, in a sibling dir:
//
// <repo>/../<repo-name>-worktrees/<id>/ git worktree checkout on task/<id>
//
// Outside-the-repo is deliberate: it is a pure development environment. With no
// repo `.amenbo` in its ancestry, amenbo commands run there (e.g. running the
// dev build for debug verification) cannot reach the real backlog — they fall
// to an isolated/throwaway store. That keeps two concerns physically apart:
//
// - Project management (status/comment/done) → the PROD `amenbo` binary
// run from the MAIN repo, against the real backlog. devtool's own reservation
// does exactly this (prod binary, anchored to the main worktree root).
// - Debug verification (does my code work) → the worktree's dev build against
// a throwaway store (e.g. `make verify`), inside the outside worktree.
//
// devtool does NOT provision any amenbo store; isolation comes from the worktree
// living outside the repo plus `make verify`'s mktemp store.
package main
import (
"errors"
"flag"
"fmt"
"os"
"path/filepath"
"strings"
)
func main() {
args := os.Args[1:]
if len(args) == 0 {
usage()
os.Exit(2)
}
switch args[0] {
case "task":
taskCmd(args[1:])
case "help", "-h", "--help":
usage()
default:
logf("devtool: unknown command %q", args[0])
usage()
os.Exit(2)
}
}
func usage() {
logf(`devtool — amenbo developer-support CLI
Usage:
devtool task start <id> [--base main] [--no-reserve] [--no-deps]
devtool task finish <id> [--base main] [--force] [--reset]
task start reserve <id> (todo→in_progress, prod amenbo from the main repo) and
add a git worktree on branch task/<id> in a sibling dir OUTSIDE the
repo — a pure dev env. For a GUI checkout it also runs a best-effort
'npm ci' in app/ (skip with --no-deps). Manage the backlog
(comment/done) from the main repo; verify code there with
'make verify'.
task finish safely tear it down: refuse unless the worktree is clean and the
branch is merged into --base (override with --force).`)
}
func taskCmd(args []string) {
if len(args) == 0 {
usage()
os.Exit(2)
}
sub := args[0]
rest := args[1:]
// id is the first positional, before any flags: `task start <id> --flag`.
var id string
if len(rest) > 0 && !strings.HasPrefix(rest[0], "-") {
id, rest = rest[0], rest[1:]
}
switch sub {
case "start":
fs := flag.NewFlagSet("task start", flag.ExitOnError)
base := fs.String("base", "main", "branch to base the worktree on")
noReserve := fs.Bool("no-reserve", false, "assume the task is already in_progress; only verify")
noDeps := fs.Bool("no-deps", false, "skip the best-effort `npm ci` for GUI app/ checkouts")
fs.Parse(rest)
id = mustID(id)
if err := taskStart(id, *base, *noReserve, *noDeps); err != nil {
logf("devtool: %v", err)
os.Exit(1)
}
case "finish":
fs := flag.NewFlagSet("task finish", flag.ExitOnError)
base := fs.String("base", "main", "branch the task must be merged into")
force := fs.Bool("force", false, "tear down even if dirty or unmerged")
rel := fs.Bool("reset", false, "also return the task to todo (amenbo status)")
fs.Parse(rest)
id = mustID(id)
if err := taskFinish(id, *base, *force, *rel); err != nil {
logf("devtool: %v", err)
os.Exit(1)
}
default:
logf("devtool: unknown task subcommand %q", sub)
usage()
os.Exit(2)
}
}
// mustID validates the task reference and returns its canonical form: the
// conversational number (digits only), with an optional leading '#' stripped.
//
// Requiring the number — not a ULID or an id-prefix — is what keeps the worktree
// dir and branch name canonical (`task/<number>`). paths()/branchName() derive
// those names verbatim from this string, so two `task start` invocations for the
// same task in different reference forms (e.g. `696` vs its ULID) would otherwise
// produce two differently-named worktrees and slip past the "already exists"
// guard — silently double-starting the task in parallel sessions. Pinning the
// name to the number makes that second start collide on the same path/branch and
// be rejected.
func mustID(id string) string {
canon, err := canonicalID(id)
if err != nil {
logf("devtool: %v", err)
os.Exit(2)
}
return canon
}
// canonicalID normalizes a task reference to its conversational number (digits
// only), stripping an optional leading '#'. It rejects any other form (ULID,
// id-prefix, empty) — that rejection is the whole point (see mustID's doc).
func canonicalID(id string) (string, error) {
id = strings.TrimPrefix(id, "#")
if id == "" {
return "", fmt.Errorf("missing <id>")
}
for _, r := range id {
if r < '0' || r > '9' {
return "", fmt.Errorf("task ref %q must be the conversational number (digits only, e.g. 696 or #696) — not a ULID or id-prefix, so the worktree/branch name stays canonical and a double-start is caught", id)
}
}
return id, nil
}
// paths resolves the main repo root and the per-task worktree dir for id. The
// worktree lives OUTSIDE the repo, in a sibling `<repo-name>-worktrees/` dir, so
// it has no repo `.amenbo` in its ancestry (see the package doc).
func paths(id string) (root, base, worktree string, err error) {
cwd, err := os.Getwd()
if err != nil {
return
}
root, err = gitRoot(cwd)
if err != nil {
return "", "", "", fmt.Errorf("not inside a git repository: %w", err)
}
base = filepath.Join(filepath.Dir(root), filepath.Base(root)+"-worktrees")
worktree = filepath.Join(base, id)
return
}
func branchName(id string) string { return "task/" + id }
// verifyReserved confirms the backlog holds the task in_progress before we spend
// work on a worktree. Double-work is guarded by `status` alone, so
// status==in_progress (set by us via task status) is the whole check. Reservation
// is blind to who asks: any session that finds it todo can reserve it, and
// in_progress is the advisory "someone is on it" signal.
func verifyReserved(id string, t task) error {
if t.Status != "in_progress" {
return fmt.Errorf("task %s is %q, not in_progress (reserve failed?)", id, t.Status)
}
return nil
}
// existingWorktreeError names WHICH accident a pre-existing worktree is, because the
// two look identical on disk and read the same to whoever hits the refusal. A worktree
// on a task the backlog holds in_progress is another session at work: the refusal has
// to say so, or it reads as the harmless one — the stale worktree of a task already
// handed back — and gets waved past. It is only advisory: the backlog is the authority
// on who reserved what, and a task can legitimately change hands mid-worktree.
func existingWorktreeError(root, id, worktree string) error {
t, err := show(root, id)
if err != nil {
return worktreeConflict(id, worktree, "backlog unreadable")
}
return worktreeConflict(id, worktree, t.Status)
}
// worktreeConflict is the refusal itself, split from the lookup so both arms are
// testable. in_progress is the one status that means someone else is working here.
func worktreeConflict(id, worktree, status string) error {
if status == "in_progress" {
return fmt.Errorf(`ANOTHER SESSION IS PROBABLY ON TASK %s — hands off.
%s exists and the backlog holds the task in_progress.
Do not look inside it, do not judge whether it is stale, do not delete it, and do
not ask whether to take it over. Take a different task (`+"`amenbo agent --json`"+`).
Only if you know the worktree is your own leftover: `+"`devtool task finish %s`"+`.`, id, worktree, id)
}
return fmt.Errorf(`%s already exists, and the backlog does not hold task %s in_progress (status: %s).
That reads as a worktree you left behind. Tear it down before starting again:
`+"`devtool task finish %s`"+`.`, worktree, id, status, id)
}
func taskStart(id, base string, noReserve, noDeps bool) error {
root, wtBase, worktree, err := paths(id)
if err != nil {
return err
}
if _, err := os.Stat(worktree); err == nil {
return existingWorktreeError(root, id, worktree)
}
// 1. Reserve the task (todo→in_progress) and verify against the backlog: status
// must be in_progress. `task status in_progress` is the reservation, and
// status==in_progress is the whole check.
if !noReserve {
if _, err := setStatus(root, id, "in_progress"); err != nil {
var ae *amErr
if errors.As(err, &ae) && ae.Code == "already_reserved" {
return fmt.Errorf(`ANOTHER SESSION RESERVED TASK %s FIRST — hands off.
The reservation is a compare-and-swap: it only takes from todo, and this one
did not. Someone else is on this task.
Take a different task (`+"`amenbo agent --json`"+`). Do not reserve it by another
route, and do not start work on it here.`, id)
}
return fmt.Errorf("reserve: %w", err)
}
}
t, err := show(root, id)
if err != nil {
return fmt.Errorf("verify: %w", err)
}
if err := verifyReserved(id, t); err != nil {
return err
}
// 2. Create the worktree on a fresh branch.
if branchExists(root, branchName(id)) {
return fmt.Errorf("branch %s already exists — finish or delete it first", branchName(id))
}
if err := os.MkdirAll(wtBase, 0o755); err != nil {
return err
}
if err := worktreeAdd(root, worktree, branchName(id), base); err != nil {
return fmt.Errorf("worktree add: %w", err)
}
// 3. Best-effort: warm the GUI app's node_modules so a GUI task's worktree is
// ready for `cd app && npm run typecheck/build/test` without a manual `npm ci`.
// Never fatal — the worktree/branch/reservation already exist (see ensureAppDeps).
if !noDeps {
ensureAppDeps(worktree)
}
// Human summary to stderr; an eval-able `cd` to stdout so callers can
// `eval "$(devtool task start <id>)"` to enter the worktree.
logf("✓ task %s ready: %s", id, t.Title)
logf(" dev env : %s (branch %s)", worktree, branchName(id))
logf(" code/build/test here; debug-verify with `make verify`")
logf(" backlog : run amenbo (status/comment/done) from the MAIN repo: %s", root)
// Front-load the context an agent must read before coding: the task's notes, the
// decisions linked to it (the "why"), and the latest comments — surfaced by
// `amenbo task show` in its human form. Printing it at reserve time means it can't
// be skipped by reading notes alone. Best-effort: a show failure never fails start
// (the worktree/branch are already in place).
if ctx, err := showHuman(root, id); err == nil {
if ctx = strings.TrimRight(ctx, "\n"); ctx != "" {
logf(" context : read before coding (notes / linked decisions / latest comments) —")
for _, line := range strings.Split(ctx, "\n") {
logf(" %s", line)
}
}
}
fmt.Printf("cd %s\n", shellQuote(worktree))
return nil
}
func taskFinish(id, base string, force, rel bool) error {
root, wtBase, worktree, err := paths(id)
if err != nil {
return err
}
if _, err := os.Stat(worktree); err != nil {
return fmt.Errorf("no worktree for task %s (%s missing)", id, worktree)
}
if !force {
clean, err := isClean(worktree)
if err != nil {
return fmt.Errorf("check worktree: %w (use --force to override)", err)
}
if !clean {
return fmt.Errorf("worktree %s has uncommitted changes — commit them or use --force", worktree)
}
merged, err := isMerged(root, branchName(id), base)
if err != nil {
return err
}
if !merged {
return fmt.Errorf("branch %s is not merged into %s — merge it or use --force", branchName(id), base)
}
}
if err := worktreeRemove(root, worktree, force); err != nil {
return fmt.Errorf("worktree remove: %w", err)
}
if err := branchDelete(root, branchName(id), force); err != nil {
return fmt.Errorf("branch delete: %w", err)
}
if rel {
if err := unreserve(root, id); err != nil {
logf("devtool: warning: returning the task to todo failed: %v", err)
}
}
// git worktree remove deletes the dir; clean up any leftover defensively.
if err := os.RemoveAll(worktree); err != nil {
return fmt.Errorf("remove %s: %w", worktree, err)
}
// Prune the base dir when it is now empty; a non-empty error means another
// task's worktree still lives there, so leave it be.
_ = os.Remove(wtBase)
logf("✓ torn down task %s (worktree + branch %s removed)", id, branchName(id))
if !rel {
logf(" note: the task's in_progress status was left as-is (use --reset, or `amenbo task done %s`)", id)
}
return nil
}
// shellQuote single-quotes a path for safe eval in a POSIX shell.
func shellQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}
amenbo.go — 予約まわり。Amenbo に触るのはここだけ
package main
import (
"encoding/json"
"fmt"
"os"
)
// amenboBin is the backlog binary. The backlog (status/show) lives in the PROD
// store reached via the repo's `.amenbo` pointer, so the default is `amenbo`.
// Override with AMENBO_BIN (e.g. amenbo-dev) for isolated testing.
func amenboBin() string {
if b := os.Getenv("AMENBO_BIN"); b != "" {
return b
}
return "amenbo"
}
// amErr mirrors amenbo's `{ "error": { code, message, hint } }` envelope.
type amErr struct {
Code string `json:"code"`
Message string `json:"message"`
Hint string `json:"hint"`
}
func (e *amErr) Error() string {
if e.Hint != "" {
return e.Message + " (" + e.Hint + ")"
}
return e.Message
}
// task holds the fields we need to drive worktree isolation and verify the
// reservation. amenbo emits many more; we decode only these. Double-work is
// guarded by `status` alone, so worktree isolation reserves a task by moving it
// todo→in_progress.
type task struct {
Title string `json:"title"`
Status string `json:"status"`
}
// setStatus runs `amenbo task status <id> <status>` in repoDir (so the `.amenbo`
// pointer resolves to the backlog store) and returns the updated task. `task
// status --json` wraps the task as `{ ok, task: {...} }`. in_progress reserves,
// todo releases.
func setStatus(repoDir, id, status string) (task, error) {
out, err := run(repoDir, amenboBin(), "task", "status", id, status, "--actor", "ai", "--json")
if err != nil {
return task{}, err
}
var env struct {
OK bool `json:"ok"`
Task task `json:"task"`
Error *amErr `json:"error"`
}
if err := json.Unmarshal([]byte(out), &env); err != nil {
return task{}, fmt.Errorf("parse status output: %w", err)
}
if env.Error != nil {
return task{}, env.Error
}
return env.Task, nil
}
// show runs `amenbo task show`. Unlike add/status, `task show --json` emits the
// task fields at the TOP level (no `task` wrapper), so we decode into a struct
// that embeds task alongside the optional error envelope.
func show(repoDir, id string) (task, error) {
out, err := run(repoDir, amenboBin(), "task", "show", id, "--json")
if err != nil {
return task{}, err
}
var res struct {
task
Error *amErr `json:"error"`
}
if err := json.Unmarshal([]byte(out), &res); err != nil {
return task{}, fmt.Errorf("parse show output: %w", err)
}
if res.Error != nil {
return task{}, res.Error
}
return res.task, nil
}
// showHuman runs `amenbo task show <id>` in its human form and returns the text. Unlike show
// (which parses --json fields), this is the operator-facing rendering that bundles the four things an
// agent must read before coding — body, notes, the linked decisions (the "why"), and the latest
// comments. task start front-loads it so the context can't be skipped by reading notes alone.
func showHuman(repoDir, id string) (string, error) {
return run(repoDir, amenboBin(), "task", "show", id)
}
// unreserve moves a task back to todo (used on teardown of an unfinished task).
// todo is how you hand a reservation back.
func unreserve(repoDir, id string) error {
_, err := run(repoDir, amenboBin(), "task", "status", id, "todo", "--actor", "ai", "--json")
return err
}
git.go — worktree とブランチの操作。「メインの作業ツリーを基準にする」ところが肝
package main
import (
"path/filepath"
"strings"
)
// gitRoot returns the MAIN worktree root for the repo containing dir — not the
// current linked worktree. This is what makes devtool behave the same whether
// it is invoked from the main checkout or from inside a per-task worktree
// (where the naive --show-toplevel would point at the worktree itself and the
// .worktrees/<id> layout would resolve wrong). --git-common-dir resolves to the
// main repo's `.git`, whose parent is the main worktree root.
func gitRoot(dir string) (string, error) {
common, err := run(dir, "git", "rev-parse", "--path-format=absolute", "--git-common-dir")
if err != nil {
return "", err
}
return filepath.Dir(common), nil
}
// worktreeAdd creates a new worktree at path checked out to a fresh branch
// branched from base.
func worktreeAdd(root, path, branch, base string) error {
_, err := run(root, "git", "worktree", "add", path, "-b", branch, base)
return err
}
// worktreeRemove detaches the worktree at path. force discards local changes.
func worktreeRemove(root, path string, force bool) error {
args := []string{"worktree", "remove", path}
if force {
args = append(args, "--force")
}
_, err := run(root, "git", args...)
return err
}
// branchExists reports whether refs/heads/branch is present.
func branchExists(root, branch string) bool {
_, err := run(root, "git", "show-ref", "--verify", "--quiet", "refs/heads/"+branch)
return err == nil
}
// branchDelete deletes branch. force allows deleting an unmerged branch.
func branchDelete(root, branch string, force bool) error {
flag := "-d"
if force {
flag = "-D"
}
_, err := run(root, "git", "branch", flag, branch)
return err
}
// isClean reports whether the worktree at path has no uncommitted changes.
func isClean(path string) (bool, error) {
out, err := run(path, "git", "status", "--porcelain")
if err != nil {
return false, err
}
return strings.TrimSpace(out) == "", nil
}
// isMerged reports whether branch is fully contained in base (its tip is an
// ancestor of base), i.e. safe to delete.
func isMerged(root, branch, base string) (bool, error) {
_, err := run(root, "git", "merge-base", "--is-ancestor", branch, base)
if err == nil {
return true, nil
}
// `--is-ancestor` exits 1 (non-fatal) when not an ancestor; treat as "not
// merged" rather than a hard error.
return false, nil
}
deps.go — GUI タスクの npm ci。失敗しても start を止めない
package main
import (
"os"
"os/exec"
"path/filepath"
)
// ensureAppDeps installs the GUI app's node_modules in a freshly created
// worktree so `cd app && npm run typecheck/build/test` works without a manual
// `npm ci`. Each worktree keeps a real (gitignored) node_modules — no symlink —
// so parallel sessions stay isolated.
//
// It is strictly best-effort: a non-GUI checkout (no app/package.json), a
// missing npm, or a failed install must NEVER fail `task start`. The
// worktree/branch/reservation are already in place by the time this runs, so on any
// problem we only warn and let the developer run `npm ci` by hand.
func ensureAppDeps(worktree string) {
app := filepath.Join(worktree, "app")
if _, err := os.Stat(filepath.Join(app, "package.json")); err != nil {
return // not a GUI checkout (core/CLI task) — nothing to install.
}
if _, err := exec.LookPath("npm"); err != nil {
logf(" deps : skipped — npm not found; run `cd app && npm ci` by hand")
return
}
logf(" deps : installing app/node_modules (npm ci)…")
if _, err := run(app, "npm", "ci"); err != nil {
logf(" deps : warning — npm ci failed (%v); run `cd app && npm ci` by hand", err)
return
}
logf(" deps : app/node_modules ready")
}
run.go — 外部コマンドの実行。cd を返すので、診断は stderr へ寄せる
package main
import (
"bytes"
"fmt"
"os"
"os/exec"
"strings"
)
// logf writes diagnostics to stderr so stdout stays reserved for eval-able output.
func logf(format string, a ...any) {
fmt.Fprintf(os.Stderr, format+"\n", a...)
}
// run executes a command in dir and returns its trimmed stdout. On failure the
// error carries the captured stderr so callers can surface the real cause.
func run(dir, name string, args ...string) (string, error) {
cmd := exec.Command(name, args...)
cmd.Dir = dir
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
msg := strings.TrimSpace(stderr.String())
if msg == "" {
msg = strings.TrimSpace(stdout.String())
}
return "", fmt.Errorf("%s %s: %v: %s", name, strings.Join(args, " "), err, msg)
}
return strings.TrimSpace(stdout.String()), nil
}
読んでのとおり、大したことはしていません。git のコマンドを順番に呼んで、予約の結果を確かめて、断るところで断る。それだけです。それでも、この 580 行があるかないかで、セッションを並べられるかどうかが変わりました。