3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Claude Codeを自動制御してタスクを完全自律実行するGoツール「Sleepship」

Last updated at Posted at 2025-12-12

はじめに

Claude Codeを自動制御してタスクを完全自律実行するGoツール「Sleepship」。
上の記事では使い方や主要機能を紹介しましたが、本記事では技術実装に焦点を当てます。

1. プロセス制御アーキテクチャ

1.1 基本戦略:子プロセスとしてのClaude Code

SleepshipがClaude Codeを制御する核心部分は、Go言語のexec.Commandによる子プロセス起動です。

func executeClaude(prompt string, logFile *os.File) error {
    cmd := exec.Command("claude", "-p", "--dangerously-skip-permissions")
    cmd.Stdin = strings.NewReader(prompt)
    cmd.Dir = projectDir

    cmd.Stdout = os.Stdout
    cmd.Stderr = os.Stderr

    if err := cmd.Run(); err != nil {
        return fmt.Errorf("claude execution failed: %w", err)
    }

    return nil
}

技術的なポイント

  1. -pフラグ: プロンプトモードでClaude Codeを起動
  2. --dangerously-skip-permissions: 権限確認をスキップして完全自動化
  3. cmd.Stdin: プロンプトを標準入力経由で注入
  4. cmd.Dir: プロジェクトディレクトリをワーキングディレクトリとして設定

この設計により、Sleepshipは「プロンプトを投げてClaude Codeに実装させる」という一連の流れを完全に自動化できます。

1.2 バックグラウンドワーカーの分離設計

Sleepshipには「フォアグラウンドプロセス」と「バックグラウンドワーカー」の2層構造があります。

func runSync(cmd *cobra.Command, args []string) error {
    // フォアグラウンドプロセス:すぐにバックグラウンドを起動して終了
    if !worker {
        return spawnBackgroundWorker(taskFile)
    }

    // バックグラウンドワーカー:実際のタスク実行を担当
    // ... タスク実行ロジック ...
}

なぜこの設計にしたのか?

  • ユーザー体験の向上: コマンド実行後すぐにシェルが戻る
  • ログの完全記録: バックグラウンドプロセスの出力をファイルに記録
  • 長時間実行への対応: ターミナルを閉じても実行が継続

バックグラウンドワーカーの起動部分:

func spawnBackgroundWorker(taskFile string) error {
    // ... ログファイル作成 ...

    executable, err := os.Executable()
    if err != nil {
        return fmt.Errorf("failed to get executable path: %w", err)
    }

    // --workerフラグでバックグラウンドモードを指定
    cmdArgs := []string{"sync", taskFile, "--worker"}
    if projectDir != "" {
        cmdArgs = append(cmdArgs, "--dir", projectDir)
    }

    cmd := exec.Command(executable, cmdArgs...)
    cmd.Stdout = logFile
    cmd.Stderr = logFile

    if err := cmd.Start(); err != nil {
        return fmt.Errorf("failed to start background process: %w", err)
    }

    fmt.Printf("✅ Started background execution (PID: %d)\n", cmd.Process.Pid)
    return nil
}

設計の妙

  • 自分自身を--workerフラグ付きで再実行
  • cmd.Start()で起動後、親プロセスは即座に終了
  • 標準出力/エラーをログファイルにリダイレクト

1.3 標準入出力のハンドリング戦略

Claude Codeとの対話は標準入出力を介して行われます。ここでの設計ポイントは「ログとユーザー表示の両立」です。

func executeClaude(prompt string, logFile *os.File) error {
    cmd := exec.Command("claude", "-p", "--dangerously-skip-permissions")
    cmd.Stdin = strings.NewReader(prompt)
    cmd.Dir = projectDir

    // 画面とログファイルの両方に出力
    cmd.Stdout = os.Stdout
    cmd.Stderr = os.Stderr

    // ログファイルにはプロンプトも記録
    _, _ = fmt.Fprintf(logFile, "\n=== Claude Execution ===\n%s\n\n", time.Now().Format("2006-01-02 15:04:05"))
    _, _ = logFile.WriteString(prompt)
    _, _ = logFile.WriteString("\n\n")

    return cmd.Run()
}

工夫ポイント

  • プロンプトの記録: 実行時刻とともにログに保存(デバッグ時に重要)
  • 出力の二重化: ユーザーにはリアルタイム表示、ログには永続化
  • エラー追跡: 標準エラーも同様に記録

2. リトライメカニズムの実装

2.1 二段階リトライ戦略

Sleepshipのリトライは「タスク実行失敗」と「検証失敗」の2段階で行われます。

タスク実行のリトライループ

var lastErr error
taskRetryCount := 0

for taskRetryCount <= maxRetries {
    if err := executeTask(task, f); err != nil {
        lastErr = err
        taskRetryCount++

        if taskRetryCount > maxRetries {
            // 最終的な失敗
            return fmt.Errorf("task %d failed after %d attempts: %w", taskNum, maxRetries+1, err)
        }

        // リトライ用のプロンプト生成
        retryPrompt := fmt.Sprintf(`前回のタスク実行でエラーが発生しました (リトライ %d/%d):
エラー: %v

# タスク
%s

%s

# 指示
1. 前回のエラーを修正してください
2. このタスクを完全に実装してください
...`, taskRetryCount, maxRetries, err, task.Title, task.Description)

        if err := executeClaude(retryPrompt, f); err != nil {
            continue
        }

        lastErr = nil
        break
    }

    break
}

検証失敗のリトライループ

retryCount := 0
verificationPassed := false

for retryCount <= maxRetries {
    if err := runCommand(task.Command, f); err != nil {
        retryCount++

        if retryCount > maxRetries {
            return fmt.Errorf("verification failed after %d attempts: %w", maxRetries+1, err)
        }

        // 修正プロンプト
        fixPrompt := fmt.Sprintf(`検証コマンドが失敗しました(リトライ %d/%d 回目):

コマンド: %s
エラー: %v

# 指示
1. 上記のエラーを修正してください
2. 修正後、検証が通ることを確認してください
...`, retryCount, maxRetries, task.Command, err)

        if err := executeClaude(fixPrompt, f); err != nil {
            continue
        }

        continue
    }

    verificationPassed = true
    break
}

2.2 エラーコンテキストの保持と活用

リトライの鍵は「前回の失敗情報をAIに正確に伝えること」です。

リトライプロンプトの構造

retryPrompt := fmt.Sprintf(`前回のタスク実行でエラーが発生しました (リトライ %d/%d):
エラー: %v

# タスク
%s

%s

# 指示
1. 前回のエラーを修正してください
2. このタスクを完全に実装してください
3. 必要なファイルを作成・編集してください
4. 実装後、必ず動作確認してください
5. エラーがあれば修正してください

プロジェクトディレクトリ: %s

実装を開始してください。

実装後、以下の質問に必ず答えてください:

【成功判定】
このタスクは完全に成功しましたか?以下を確認してください:
1. 必要なファイルが作成されているか
2. 確認コマンドが成功しているか
3. エラーが発生していないか

成功の場合: "SUCCESS: このタスクは成功しました"
失敗の場合: "FAILED: このタスクは失敗しました。理由: [具体的な理由]"

という形式で必ず応答してください。`,
    taskRetryCount, maxRetries, err, task.Title, task.Description, projectDir)

プロンプト設計の工夫

  1. エラー情報の明示: エラー: %vで具体的な失敗内容を伝える
  2. リトライ回数の通知: AIに「あと何回チャンスがあるか」を認識させる
  3. 構造化された指示: 番号付きリストで明確なアクションを指示
  4. 成功判定の強制: AIに自己評価を求めることで、曖昧な完了を防ぐ

2.3 詳細なエラーログ出力

デバッグとトラブルシューティングのため、失敗時には詳細なログを出力します。

if taskRetryCount > maxRetries {
    failureMsg := fmt.Sprintf("❌ Task failed: タスク %d (\"%s\") が %d 回の試行後も失敗しました\n",
        taskNum, task.Title, maxRetries+1)
    fmt.Print(failureMsg)
    _, _ = f.WriteString(failureMsg)

    detailedError := "【失敗の詳細】\n"
    detailedError += fmt.Sprintf("  タスク番号: %d/%d\n", taskNum, len(tasks))
    detailedError += fmt.Sprintf("  タスク名: %s\n", task.Title)
    detailedError += fmt.Sprintf("  試行回数: %d回\n", maxRetries+1)
    detailedError += fmt.Sprintf("  エラー内容: %v\n", err)
    detailedError += "\n実行を停止します。リトライ不可。\n"
    fmt.Print(detailedError)
    _, _ = f.WriteString(detailedError)
}

ログ設計のポイント

  • 構造化された情報: タスク番号、名前、試行回数を明確に記録
  • 画面とログの両方に出力: fmt.Printf.WriteStringの併用
  • 日本語メッセージ: ユーザーフレンドリーなエラー報告

3. 再帰実行制御

3.1 環境変数による深度管理

SleepshipはSLEEPSHIP_DEPTH環境変数で再帰深度を追跡します。

const (
    maxRecursionDepth = 3 // 最大深度
)

func getCurrentRecursionDepth() int {
    depthStr := os.Getenv("SLEEPSHIP_DEPTH")
    if depthStr == "" {
        return 0
    }
    depth, err := strconv.Atoi(depthStr)
    if err != nil {
        return 0
    }
    return depth
}

コマンド実行時の深度インクリメント

func runCommand(command string, logFile *os.File) error {
    isSleepshipCommand := strings.Contains(command, "sleepship") ||
                         strings.Contains(command, "./bin/sleepship")

    if isSleepshipCommand {
        currentDepth := getCurrentRecursionDepth()
        if currentDepth >= maxRecursionDepth {
            warningMsg := fmt.Sprintf("⚠️ Maximum recursion depth (%d) reached. Skipping sleepship command: %s\n",
                maxRecursionDepth, command)
            fmt.Print(warningMsg)
            _, _ = logFile.WriteString(warningMsg)
            return nil // エラーではなくスキップ扱い
        }
        log.Printf("🔁 Executing recursive sleepship command (depth: %d -> %d)\n",
            currentDepth, currentDepth+1)
    }

    cmd := exec.Command("bash", "-c", command)
    cmd.Dir = projectDir

    // 環境変数を設定
    cmd.Env = os.Environ()
    if isSleepshipCommand {
        currentDepth := getCurrentRecursionDepth()
        cmd.Env = append(cmd.Env, fmt.Sprintf("SLEEPSHIP_DEPTH=%d", currentDepth+1))
    }

    output, err := cmd.CombinedOutput()
    // ...
}

3.2 なぜ最大深度を3にしたのか

設計判断の理由

  1. 実用的なユースケース:

    • 深度1: メインタスク(例: tasks-feature.txt
    • 深度2: 調査・計画タスク(例: tasks-investigation.txt
    • 深度3: 実装タスク(例: tasks-impl.txt
  2. 無限再帰の防止: 4階層以上になると、タスク構造が複雑すぎて管理困難

  3. デバッグの容易性: 深すぎるとログの追跡が困難

深度超過時の振る舞い

if currentDepth >= maxRecursionDepth {
    warningMsg := fmt.Sprintf("⚠️ Maximum recursion depth (%d) reached. Skipping sleepship command: %s\n",
        maxRecursionDepth, command)
    fmt.Print(warningMsg)
    _, _ = logFile.WriteString(warningMsg)
    return nil // エラーではなくスキップ
}

エラーではなく警告でスキップする理由

  • タスク全体を失敗扱いにすると、深度3までの成果が無駄になる
  • ユーザーに警告を出しつつ、可能な範囲で実行を継続

3.3 再帰実行の検出ロジック

Sleepshipコマンドの検出は文字列マッチングで行います。

isSleepshipCommand := strings.Contains(command, "sleepship") ||
                     strings.Contains(command, "./bin/sleepship")

この実装の利点

  • シンプル: 複雑な正規表現不要
  • 柔軟: 相対パス・絶対パスどちらでも検出可能
  • 高速: 文字列検索は軽量

潜在的な誤検出

  • echo "sleepship"のような文字列出力も検出される
  • 実用上は問題ないが、厳密には改善の余地あり

4. 設定マージシステム:優先順位の実装

4.1 3層構成の設定システム

Sleepshipの設定は「CLI > 環境変数 > デフォルト」の優先順位で適用されます。

func MergeConfig(cliConfig, envConfig, defaultConfig *Config) *Config {
    merged := &Config{}

    // Project directory
    merged.ProjectDir = selectValue(
        cliConfig.ProjectDir,
        envConfig.ProjectDir,
        defaultConfig.ProjectDir,
    )

    // Max retries (整数値は特別処理)
    merged.MaxRetries = selectIntValue(
        cliConfig.MaxRetries, cliConfig.MaxRetries >= 0,
        envConfig.MaxRetries, envConfig.MaxRetries >= 0,
        defaultConfig.MaxRetries, true,
    )

    // ...

    return merged
}

4.2 型安全な設定マージアルゴリズム

文字列値の選択

func selectValue(values ...string) string {
    for _, v := range values {
        if v != "" {
            return v
        }
    }
    return ""
}

整数値の選択

func selectIntValue(v1 int, hasV1 bool, v2 int, hasV2 bool, v3 int, hasV3 bool) int {
    if hasV1 {
        return v1
    }
    if hasV2 {
        return v2
    }
    if hasV3 {
        return v3
    }
    return 0
}

なぜ整数値にhasXXフラグが必要か?

整数のゼロ値0は「未設定」と「明示的に0を指定」を区別できません。

// 問題のある実装例
if cliConfig.MaxRetries != 0 {
    merged.MaxRetries = cliConfig.MaxRetries
}
// → ユーザーが明示的に0を指定しても、デフォルトの3が使われてしまう

Sleepshipの解決策:

// CLI設定の作成
cliConfig := &Config{
    MaxRetries: -1,  // 未設定を-1で表現
}

if cmd.Flags().Changed("max-retries") {
    cliConfig.MaxRetries = maxRetries  // フラグが指定された場合のみ設定
}

設計の利点

  • 明示的な未設定状態: -1で未設定を表現
  • フラグの変更検出: cmd.Flags().Changed()で明示的な指定を判定
  • 型安全性: コンパイル時に型チェック可能

4.3 環境変数設定の実装

type EnvConfig struct {
    ProjectDir      string
    DefaultTaskFile string
    MaxRetries      int
    LogDir          string
    StartFrom       int
    ClaudeFlags     []string

    hasProjectDir      bool
    hasDefaultTaskFile bool
    hasMaxRetries      bool
    hasLogDir          bool
    hasStartFrom       bool
    hasClaudeFlags     bool
}

func LoadFromEnv() *EnvConfig {
    config := &EnvConfig{}

    if dir := os.Getenv("SLEEPSHIP_PROJECT_DIR"); dir != "" {
        config.ProjectDir = dir
        config.hasProjectDir = true
    }

    if retriesStr := os.Getenv("SLEEPSHIP_SYNC_MAX_RETRIES"); retriesStr != "" {
        if retries, err := strconv.Atoi(retriesStr); err == nil {
            config.MaxRetries = retries
            config.hasMaxRetries = true
        }
    }

    // ...

    return config
}

設計の妙

  • プライベートなhasXXフィールド: 設定の有無を内部で管理
  • パース失敗の処理: 不正な値は無視して継続
  • プレフィックス統一: すべての環境変数にSLEEPSHIP_プレフィックス

4.4 設定適用時のログ出力

ユーザーに「どの設定が使われたか」を明示します。

if envConfig.HasMaxRetries() && !cmd.Flags().Changed("max-retries") {
    log.Printf("ℹ️  Using max-retries from environment: %d\n", maxRetries)
}
if envConfig.HasStartFrom() && !cmd.Flags().Changed("start-from") {
    log.Printf("ℹ️  Using start-from from environment: %d\n", startFrom)
}

UX上の利点

  • 設定の透明性: ユーザーが意図しない設定に気づける
  • デバッグの容易性: 「なぜこの値が使われたのか」が明確
  • 学習効果: 環境変数の存在をユーザーに教育

5. タスク依存関係の解決:構造化データの抽出

5.1 Markdownパーサーの実装

Sleepshipはタスクファイル(Markdown)を構造化データに変換します。

type Task struct {
    Title         string
    Description   string
    Command       string   // 確認コマンド
    Prerequisites string   // 前提確認コマンド
    Dependencies  []int    // 依存するタスク番号のリスト
}

func ParseTaskFile(filename string) ([]Task, error) {
    file, err := os.Open(filename)
    if err != nil {
        return nil, err
    }
    defer func() { _ = file.Close() }()

    var tasks []Task
    var currentTask *Task
    var descLines []string
    var inDependencySection bool
    var inPrerequisiteSection bool

    scanner := bufio.NewScanner(file)
    for scanner.Scan() {
        line := scanner.Text()

        // タスクタイトル("## タスク" or "## Task"で開始)
        if strings.HasPrefix(line, "## タスク") || strings.HasPrefix(line, "## Task") {
            // 前のタスクを保存
            if currentTask != nil {
                currentTask.Description = strings.Join(descLines, "\n")
                tasks = append(tasks, *currentTask)
            }

            // 新しいタスクを開始
            currentTask = &Task{
                Title:        strings.TrimPrefix(strings.TrimPrefix(line, "## タスク"), "## Task"),
                Dependencies: []int{},
            }
            descLines = []string{}
            inDependencySection = false
            inPrerequisiteSection = false
            continue
        }

        // 依存セクションヘッダー
        if currentTask != nil && (strings.HasPrefix(line, "### 依存") || strings.HasPrefix(line, "### Dependencies")) {
            inDependencySection = true
            inPrerequisiteSection = false
            continue
        }

        // 前提確認セクションヘッダー
        if currentTask != nil && (strings.HasPrefix(line, "### 前提確認") || strings.HasPrefix(line, "### Prerequisites")) {
            inPrerequisiteSection = true
            inDependencySection = false
            continue
        }

        // 他のセクションヘッダーで特殊セクションを終了
        if currentTask != nil && strings.HasPrefix(line, "###") {
            if !strings.HasPrefix(line, "### 依存") &&
                !strings.HasPrefix(line, "### Dependencies") &&
                !strings.HasPrefix(line, "### 前提確認") &&
                !strings.HasPrefix(line, "### Prerequisites") {
                inDependencySection = false
                inPrerequisiteSection = false
            }
        }

        // 依存関係の解析(例: "- 1, 2, 3")
        if currentTask != nil && inDependencySection && strings.HasPrefix(line, "- ") {
            depStr := strings.TrimPrefix(line, "- ")
            deps := parseDependencies(depStr)
            currentTask.Dependencies = append(currentTask.Dependencies, deps...)
            continue
        }

        // 前提確認コマンド("- `コマンド`"形式)
        if currentTask != nil && inPrerequisiteSection && strings.HasPrefix(line, "- `") && strings.HasSuffix(line, "`") {
            cmd := strings.TrimPrefix(line, "- `")
            cmd = strings.TrimSuffix(cmd, "`")
            currentTask.Prerequisites = cmd
            continue
        }

        // 確認コマンド("- `コマンド`"形式)
        if currentTask != nil && !inDependencySection && !inPrerequisiteSection &&
           strings.HasPrefix(line, "- `") && strings.HasSuffix(line, "`") {
            cmd := strings.TrimPrefix(line, "- `")
            cmd = strings.TrimSuffix(cmd, "`")
            currentTask.Command = cmd
            continue
        }

        // 説明行の蓄積
        if currentTask != nil && !inDependencySection && !inPrerequisiteSection &&
           line != "" && !strings.HasPrefix(line, "---") {
            descLines = append(descLines, line)
        }
    }

    // 最後のタスクを保存
    if currentTask != nil {
        currentTask.Description = strings.Join(descLines, "\n")
        tasks = append(tasks, *currentTask)
    }

    return tasks, scanner.Err()
}

5.2 状態機械によるセクション管理

パーサーは「状態機械」パターンで実装されています。

状態変数

  • inDependencySection: 依存関係セクション内か
  • inPrerequisiteSection: 前提確認セクション内か

状態遷�

  1. ### 依存inDependencySection = true
  2. ### 前提確認inPrerequisiteSection = true
  3. 他の###ヘッダー → 両方false

この設計の利点

  • 明確な責務分離: 各行の処理が状態に応じて変わる
  • 拡張性: 新しいセクションを追加しやすい
  • 可読性: フラグ名で現在の状態が明確

5.3 依存関係のバリデーション

パース後、依存関係の妥当性を検証します。

func ValidateDependencies(tasks []Task) error {
    // 有効なタスク番号のマップを作成
    validTasks := make(map[int]bool)
    for i := range tasks {
        validTasks[i+1] = true
    }

    // 各タスクの依存関係をチェック
    for i, task := range tasks {
        taskNum := i + 1
        for _, dep := range task.Dependencies {
            // 依存先が存在するか
            if !validTasks[dep] {
                return fmt.Errorf("task %d references non-existent task %d", taskNum, dep)
            }
            // 自己依存のチェック
            if dep == taskNum {
                return fmt.Errorf("task %d cannot depend on itself", taskNum)
            }
            // 前方依存の禁止(後続タスクへの依存は不可)
            if dep >= taskNum {
                return fmt.Errorf("task %d cannot depend on later task %d (dependencies must be on earlier tasks)",
                    taskNum, dep)
            }
        }
    }

    // 循環依存のチェック
    if err := checkCircularDependencies(tasks); err != nil {
        return err
    }

    return nil
}

バリデーションルール

  1. 存在チェック: 依存先タスクが実在する
  2. 自己依存禁止: タスクが自分自身に依存できない
  3. 前方依存禁止: 後続タスクに依存できない(タスク3がタスク5に依存は不可)
  4. 循環依存検出: A→B→Aのような循環を検出

前方依存を禁止する理由

タスク1: 基盤実装
タスク2: 機能A実装(タスク1に依存)
タスク3: 機能B実装(タスク2に依存)

前方依存を許可すると、実行順序が複雑になり、DAG(有向非巡回グラフ)のトポロジカルソートが必要になります。Sleepshipは「順次実行」を前提とするため、前方依存を禁止して実装をシンプルに保っています。

5.4 循環依存の検出

func checkCircularDependencies(_ []Task) error {
    // 前方依存を禁止しているため、循環依存は構造上不可能
    // この関数は将来の拡張性のために残している
    return nil
}

なぜ空実装なのか?

前方依存禁止により、循環依存は構造上発生しません:

  • タスク2がタスク1に依存可能
  • タスク1がタスク2に依存不可(前方依存)
  • よって1→2→1のような循環は不可能

関数を残す理由

  • 将来的に前方依存を許可する可能性
  • コードの意図を明示(「循環依存チェックは考慮済み」を示す)

6. 実行履歴トラッキング

6.1 履歴エントリの構造設計

type Entry struct {
    TaskFile     string        `json:"task_file"`
    ExecutedAt   time.Time     `json:"executed_at"`
    Success      bool          `json:"success"`
    Duration     time.Duration `json:"duration"`
    TaskCount    int           `json:"task_count"`
    ErrorMessage string        `json:"error_message,omitempty"`
    StartFrom    int           `json:"start_from,omitempty"`
    MaxRetries   int           `json:"max_retries,omitempty"`
    BranchName   string        `json:"branch_name,omitempty"`
}

各フィールドの役割

フィールド 役割
TaskFile string 実行したタスクファイル名
ExecutedAt time.Time 実行日時(タイムスタンプ)
Success bool 成功/失敗フラグ
Duration time.Duration 実行時間
TaskCount int タスク総数
ErrorMessage string エラーメッセージ(失敗時のみ)
StartFrom int 開始タスク番号
MaxRetries int リトライ上限
BranchName string Git ブランチ名

omitemptyタグの使用

ErrorMessage string `json:"error_message,omitempty"`

成功時はErrorMessageをJSONから省略し、ファイルサイズを削減。

6.2 履歴の永続化戦略

const (
    historyDir  = ".sleepship"
    historyFile = "history.json"
)

func (h *History) Save(projectDir string) error {
    historyPath := getHistoryPath(projectDir)

    // ディレクトリ作成
    dir := filepath.Dir(historyPath)
    if err := os.MkdirAll(dir, 0755); err != nil {
        return fmt.Errorf("failed to create history directory: %w", err)
    }

    // JSON整形
    data, err := json.MarshalIndent(h, "", "  ")
    if err != nil {
        return fmt.Errorf("failed to marshal history: %w", err)
    }

    // ファイル書き込み(0600: 所有者のみ読み書き可能)
    if err := os.WriteFile(historyPath, data, 0600); err != nil {
        return fmt.Errorf("failed to write history file: %w", err)
    }

    return nil
}

設計ポイント

  1. .sleepshipディレクトリ: Gitignore対象、プロジェクトごとの履歴
  2. JSON形式: 可読性と拡張性のバランス
  3. MarshalIndent: 人間が読めるように整形(デバッグ時に重要)
  4. パーミッション0600: セキュリティ考慮(他ユーザーから読み取り不可)

6.3 履歴のクエリAPI

type History struct {
    Entries []Entry `json:"entries"`
}

// 最新N件を取得
func (h *History) GetLast(n int) []Entry {
    if n <= 0 {
        return []Entry{}
    }

    if n >= len(h.Entries) {
        return h.Entries
    }

    return h.Entries[len(h.Entries)-n:]
}

// 失敗した実行のみ取得
func (h *History) GetFailed() []Entry {
    var failed []Entry
    for _, entry := range h.Entries {
        if !entry.Success {
            failed = append(failed, entry)
        }
    }
    return failed
}

// 成功した実行のみ取得
func (h *History) GetSucceeded() []Entry {
    var succeeded []Entry
    for _, entry := range h.Entries {
        if entry.Success {
            succeeded = append(succeeded, entry)
        }
    }
    return succeeded
}

API設計の考慮点

  • 不変性: 元のスライスを変更せず、新しいスライスを返す
  • ゼロ値の扱い: n <= 0で空スライスを返す
  • 境界チェック: nが大きすぎる場合は全件返す

6.4 履歴記録の呼び出しタイミング

func runSync(cmd *cobra.Command, args []string) error {
    startTime := time.Now()

    // ... タスク実行 ...

    // 失敗時の記録
    if err != nil {
        duration := time.Since(startTime)
        histErr := history.Record(projectDir, taskFile, branchName, false, duration,
            len(tasks), startFrom, maxRetries, fmt.Sprintf("Task %d failed: %v", taskNum, err))
        if histErr != nil {
            log.Printf("⚠️ Warning: Failed to record history: %v\n", histErr)
        }
        return err
    }

    // 成功時の記録
    duration := time.Since(startTime)
    if err := history.Record(projectDir, taskFile, branchName, true, duration,
        len(tasks), startFrom, maxRetries, ""); err != nil {
        log.Printf("⚠️ Warning: Failed to record history: %v\n", histErr)
    }

    return nil
}

記録タイミングの設計

  1. 開始時に時刻記録: startTime := time.Now()
  2. 終了時に経過時間計算: time.Since(startTime)
  3. 失敗時も成功時も記録: どちらのケースも履歴に残す
  4. 記録失敗は警告のみ: 履歴記録の失敗でタスク全体を失敗させない

なぜ履歴記録の失敗を無視するのか?

if histErr != nil {
    log.Printf("⚠️ Warning: Failed to record history: %v\n", histErr)
}
// エラーを返さず、処理を継続

理由:

  • 履歴はあくまで補助機能(タスク実行が本質)
  • 履歴記録失敗でタスク全体を失敗扱いにするのは過剰
  • ユーザーには警告を出し、問題があることを通知

まとめ:設計思想の共通点

Sleepshipの実装を通して見えてくる、共通の設計思想があります。

1. 失敗への寛容性

  • リトライ機構による自動回復
  • 履歴記録失敗での処理継続
  • 再帰深度超過でのスキップ処理

システムは失敗を前提として設計され、可能な限り復旧を試みます。

2. 透明性の重視

  • 詳細なログ出力
  • 設定の適用元を明示
  • エラーメッセージの構造化

ユーザーに「何が起きているか」を明確に伝えることを重視しています。

3. シンプルさの追求

  • 前方依存の禁止による実装簡素化
  • 文字列マッチングによるSleepship検出
  • 状態機械によるパーサー実装

複雑なアルゴリズムを避け、理解しやすいコードを目指しています。

4. 拡張性の確保

  • checkCircularDependenciesの空実装(将来の拡張用)
  • エイリアスの循環参照検出
  • 設定マージの柔軟な優先順位

将来の機能追加を見据えた設計になっています。

おわりに

本記事では、Sleepshipの技術実装を6つの側面から解剖しました。

  1. プロセス制御アーキテクチャ
  2. リトライメカニズム
  3. 再帰実行制御
  4. 設定マージシステム
  5. タスク依存関係の解決
  6. 実行履歴トラッキング

これらの実装は、単なる「動くコード」ではなく、失敗への寛容性、透明性、シンプルさ、拡張性といった設計思想に裏打ちされています。

もしあなたがCLIツールを作るなら、あるいはAIを自動制御するシステムを構築するなら、これらの実装パターンがヒントになるかもしれません。

SleepshipのコードはGitHubで公開されています。

3
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?