はじめに
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
}
技術的なポイント:
-
-pフラグ: プロンプトモードでClaude Codeを起動 -
--dangerously-skip-permissions: 権限確認をスキップして完全自動化 -
cmd.Stdin: プロンプトを標準入力経由で注入 -
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)
プロンプト設計の工夫:
-
エラー情報の明示:
エラー: %vで具体的な失敗内容を伝える - リトライ回数の通知: AIに「あと何回チャンスがあるか」を認識させる
- 構造化された指示: 番号付きリストで明確なアクションを指示
- 成功判定の強制: 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.Printとf.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: メインタスク(例:
tasks-feature.txt) - 深度2: 調査・計画タスク(例:
tasks-investigation.txt) - 深度3: 実装タスク(例:
tasks-impl.txt)
- 深度1: メインタスク(例:
-
無限再帰の防止: 4階層以上になると、タスク構造が複雑すぎて管理困難
-
デバッグの容易性: 深すぎるとログの追跡が困難
深度超過時の振る舞い:
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: 前提確認セクション内か
状態遷�:
-
### 依存→inDependencySection = true -
### 前提確認→inPrerequisiteSection = true - 他の
###ヘッダー → 両方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
}
バリデーションルール:
- 存在チェック: 依存先タスクが実在する
- 自己依存禁止: タスクが自分自身に依存できない
- 前方依存禁止: 後続タスクに依存できない(タスク3がタスク5に依存は不可)
- 循環依存検出: 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
}
設計ポイント:
-
.sleepshipディレクトリ: Gitignore対象、プロジェクトごとの履歴 - JSON形式: 可読性と拡張性のバランス
-
MarshalIndent: 人間が読めるように整形(デバッグ時に重要) -
パーミッション
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
}
記録タイミングの設計:
-
開始時に時刻記録:
startTime := time.Now() -
終了時に経過時間計算:
time.Since(startTime) - 失敗時も成功時も記録: どちらのケースも履歴に残す
- 記録失敗は警告のみ: 履歴記録の失敗でタスク全体を失敗させない
なぜ履歴記録の失敗を無視するのか?
if histErr != nil {
log.Printf("⚠️ Warning: Failed to record history: %v\n", histErr)
}
// エラーを返さず、処理を継続
理由:
- 履歴はあくまで補助機能(タスク実行が本質)
- 履歴記録失敗でタスク全体を失敗扱いにするのは過剰
- ユーザーには警告を出し、問題があることを通知
まとめ:設計思想の共通点
Sleepshipの実装を通して見えてくる、共通の設計思想があります。
1. 失敗への寛容性
- リトライ機構による自動回復
- 履歴記録失敗での処理継続
- 再帰深度超過でのスキップ処理
システムは失敗を前提として設計され、可能な限り復旧を試みます。
2. 透明性の重視
- 詳細なログ出力
- 設定の適用元を明示
- エラーメッセージの構造化
ユーザーに「何が起きているか」を明確に伝えることを重視しています。
3. シンプルさの追求
- 前方依存の禁止による実装簡素化
- 文字列マッチングによるSleepship検出
- 状態機械によるパーサー実装
複雑なアルゴリズムを避け、理解しやすいコードを目指しています。
4. 拡張性の確保
-
checkCircularDependenciesの空実装(将来の拡張用) - エイリアスの循環参照検出
- 設定マージの柔軟な優先順位
将来の機能追加を見据えた設計になっています。
おわりに
本記事では、Sleepshipの技術実装を6つの側面から解剖しました。
- プロセス制御アーキテクチャ
- リトライメカニズム
- 再帰実行制御
- 設定マージシステム
- タスク依存関係の解決
- 実行履歴トラッキング
これらの実装は、単なる「動くコード」ではなく、失敗への寛容性、透明性、シンプルさ、拡張性といった設計思想に裏打ちされています。
もしあなたがCLIツールを作るなら、あるいはAIを自動制御するシステムを構築するなら、これらの実装パターンがヒントになるかもしれません。
SleepshipのコードはGitHubで公開されています。