From cdedb4326ee3934d5820d920e5b259e365a129fa Mon Sep 17 00:00:00 2001 From: nrs <38722970+nrslib@users.noreply.github.com> Date: Mon, 9 Feb 2026 00:17:47 +0900 Subject: [PATCH] github-issue-157-takt-run-ni-p (#160) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * caffeinate に -d フラグを追加し、ディスプレイスリープ中の App Nap によるプロセス凍結を防止 * takt 対話モードの save_task を takt add と同じ worktree 設定フローに統一 takt 対話モードで Save Task を選択した際に worktree/branch/auto_pr の 設定プロンプトがスキップされ、takt run で clone なしに実行されて成果物が 消失するバグを修正。promptWorktreeSettings() を共通関数として抽出し、 saveTaskFromInteractive() と addTask() の両方から使用するようにした。 * Release v0.9.0 * takt: github-issue-157-takt-run-ni-p --- CHANGELOG.md | 35 +++++ README.md | 18 ++- docs/README.ja.md | 18 ++- package-lock.json | 4 +- package.json | 2 +- src/__tests__/runAllTasks-concurrency.test.ts | 7 + src/__tests__/saveTaskFile.test.ts | 96 ++++++++++++- src/__tests__/sleep.test.ts | 2 +- src/__tests__/workerPool.test.ts | 129 ++++++++++++++++-- src/core/models/global-config.ts | 2 + src/core/models/schemas.ts | 2 + src/features/tasks/add/index.ts | 69 ++++++---- .../tasks/execute/parallelExecution.ts | 112 ++++++++++++--- src/features/tasks/execute/taskExecution.ts | 2 +- src/infra/config/global/globalConfig.ts | 5 + src/shared/utils/sleep.ts | 6 +- 16 files changed, 441 insertions(+), 68 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 62ab39a..4321142 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,41 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [0.9.0] - 2026-02-08 + +### Added + +- **`takt catalog` command**: List available facets (personas, policies, knowledge, instructions, output-contracts) across layers (builtin/user/project) +- **`compound-eye` builtin piece**: Multi-model review — sends the same instruction to Claude and Codex simultaneously, then synthesizes both responses +- **Parallel task execution**: `takt run` now uses a worker pool for concurrent task execution (controlled by `concurrency` config, default: 1) +- **Rich line editor in interactive mode**: Shift+Enter for multiline input, cursor movement (arrow keys, Home/End), Option+Arrow word movement, Ctrl+A/E/K/U/W editing, paste bracket mode support +- **Movement preview in interactive mode**: Injects piece movement structure (persona + instruction) into the AI planner for improved task analysis (`interactive_preview_movements` config, default: 3) +- **MCP server configuration**: Per-movement MCP (Model Context Protocol) server settings with stdio/SSE/HTTP transport support +- **Facet-level eject**: `takt eject persona coder` — eject individual facets by type and name for customization +- **3-layer facet resolution**: Personas, policies, and other facets resolved via project → user → builtin lookup (name-based references supported) +- **`pr-commenter` persona**: Specialized persona for posting review findings as GitHub PR comments +- **`notification_sound` config**: Enable/disable notification sounds (default: true) +- **Prompt log viewer**: `tools/prompt-log-viewer.html` for visualizing prompt-response pairs during debugging +- Auto-PR base branch now set to the current branch before branch creation + +### Changed + +- Unified planner and architect-planner: extracted design knowledge into knowledge facets, merged into planner. Removed architect movement from default/coding pieces (plan → implement direct transition) +- Replaced readline with raw-mode line editor in interactive mode (cursor management, inter-line movement, Kitty keyboard protocol) +- Unified interactive mode `save_task` with `takt add` worktree setup flow +- Added `-d` flag to caffeinate to prevent App Nap process freezing during display sleep +- Issue references now routed through interactive mode (previously executed directly, now used as initial input) +- SDK update: `@anthropic-ai/claude-agent-sdk` v0.2.34 → v0.2.37 +- Enhanced interactive session scoring prompts with piece structure information + +### Internal + +- Extracted `resource-resolver.ts` for facet resolution logic (separated from `pieceParser.ts`) +- Extracted `parallelExecution.ts` (worker pool), `resolveTask.ts` (task resolution), `sigintHandler.ts` (shared SIGINT handler) +- Unified session key generation via `session-key.ts` +- New `lineEditor.ts` (raw-mode terminal input, escape sequence parsing, cursor management) +- Extensive test additions: catalog, facet-resolution, eject-facet, lineEditor, formatMovementPreviews, models, debug, strip-ansi, workerPool, runAllTasks-concurrency, session-key, interactive (major expansion), cli-routing-issue-resolve, parallel-logger, engine-parallel-failure, StreamDisplay, getCurrentBranch, globalConfig-defaults, pieceExecution-debug-prompts, selectAndExecute-autoPr, it-notification-sound, it-piece-loader, permission-mode (expansion) + ## [0.8.0] - 2026-02-08 alpha.1 の内容を正式リリース。機能変更なし。 diff --git a/README.md b/README.md index d5e1e28..59c39ba 100644 --- a/README.md +++ b/README.md @@ -262,6 +262,14 @@ takt clear # Deploy builtin pieces/personas as Claude Code Skill takt export-cc +# List available facets across layers +takt catalog +takt catalog personas + +# Eject a specific facet for customization +takt eject persona coder +takt eject instruction plan --global + # Preview assembled prompts for each movement and phase takt prompt [piece] @@ -432,15 +440,16 @@ TAKT includes multiple builtin pieces: | Piece | Description | |----------|-------------| -| `default` | Full development piece: plan → architecture design → implement → AI review → parallel review (architect + security) → supervisor approval. Includes fix loops at each review stage. | +| `default` | Full development piece: plan → implement → AI review → parallel review (architect + QA) → supervisor approval. Includes fix loops at each review stage. | | `minimal` | Quick piece: plan → implement → review → supervisor. Minimal steps for fast iteration. | | `review-fix-minimal` | Review-focused piece: review → fix → supervisor. For iterative improvement based on review feedback. | | `research` | Research piece: planner → digger → supervisor. Autonomously executes research without asking questions. | | `expert` | Full-stack development piece: architecture, frontend, security, QA reviews with fix loops. | | `expert-cqrs` | Full-stack development piece (CQRS+ES specialized): CQRS+ES, frontend, security, QA reviews with fix loops. | | `magi` | Deliberation system inspired by Evangelion. Three AI personas (MELCHIOR, BALTHASAR, CASPER) analyze and vote. | -| `coding` | Lightweight development piece: architect-planner → implement → parallel review (AI antipattern + architecture) → fix. Fast feedback loop without supervisor. | +| `coding` | Lightweight development piece: planner → implement → parallel review (AI antipattern + architecture) → fix. Fast feedback loop without supervisor. | | `passthrough` | Thinnest wrapper. Pass task directly to coder as-is. No review. | +| `compound-eye` | Multi-model review: sends the same instruction to Claude and Codex simultaneously, then synthesizes both responses. | | `review-only` | Read-only code review piece that makes no changes. | **Hybrid Codex variants** (`*-hybrid-codex`): Each major piece has a Codex variant where the coder agent runs on Codex while reviewers use Claude. Available for: default, minimal, expert, expert-cqrs, passthrough, review-fix-minimal, coding. @@ -466,6 +475,7 @@ Use `takt switch` to switch pieces. | **research-planner** | Research task planning and scope definition | | **research-digger** | Deep investigation and information gathering | | **research-supervisor** | Research quality validation and completeness assessment | +| **pr-commenter** | Posts review findings as GitHub PR comments | ## Custom Personas @@ -531,6 +541,9 @@ provider: claude # Default provider: claude or codex model: sonnet # Default model (optional) branch_name_strategy: romaji # Branch name generation: 'romaji' (fast) or 'ai' (slow) prevent_sleep: false # Prevent macOS idle sleep during execution (caffeinate) +notification_sound: true # Enable/disable notification sounds +concurrency: 1 # Parallel task count for takt run (1-10, default: 1 = sequential) +interactive_preview_movements: 3 # Movement previews in interactive mode (0-10, default: 3) # API Key configuration (optional) # Can be overridden by environment variables TAKT_ANTHROPIC_API_KEY / TAKT_OPENAI_API_KEY @@ -746,6 +759,7 @@ Special `next` values: `COMPLETE` (success), `ABORT` (failure) | `permission_mode` | - | Permission mode: `readonly`, `edit`, `full` (provider-independent) | | `output_contracts` | - | Output contract definitions for report files | | `quality_gates` | - | AI directives for movement completion requirements | +| `mcp_servers` | - | MCP (Model Context Protocol) server configuration (stdio/SSE/HTTP) | ## API Usage Example diff --git a/docs/README.ja.md b/docs/README.ja.md index 535e8ee..8121444 100644 --- a/docs/README.ja.md +++ b/docs/README.ja.md @@ -258,6 +258,14 @@ takt clear # ビルトインピース・エージェントを Claude Code Skill としてデプロイ takt export-cc +# 利用可能なファセットをレイヤー別に一覧表示 +takt catalog +takt catalog personas + +# 特定のファセットをカスタマイズ用にコピー +takt eject persona coder +takt eject instruction plan --global + # 各ムーブメント・フェーズの組み立て済みプロンプトをプレビュー takt prompt [piece] @@ -428,15 +436,16 @@ TAKTには複数のビルトインピースが同梱されています: | ピース | 説明 | |------------|------| -| `default` | フル開発ピース: 計画 → アーキテクチャ設計 → 実装 → AI レビュー → 並列レビュー(アーキテクト+セキュリティ)→ スーパーバイザー承認。各レビュー段階に修正ループあり。 | +| `default` | フル開発ピース: 計画 → 実装 → AI レビュー → 並列レビュー(アーキテクト+QA)→ スーパーバイザー承認。各レビュー段階に修正ループあり。 | | `minimal` | クイックピース: 計画 → 実装 → レビュー → スーパーバイザー。高速イテレーション向けの最小構成。 | | `review-fix-minimal` | レビュー重視ピース: レビュー → 修正 → スーパーバイザー。レビューフィードバックに基づく反復改善向け。 | | `research` | リサーチピース: プランナー → ディガー → スーパーバイザー。質問せずに自律的にリサーチを実行。 | | `expert` | フルスタック開発ピース: アーキテクチャ、フロントエンド、セキュリティ、QA レビューと修正ループ。 | | `expert-cqrs` | フルスタック開発ピース(CQRS+ES特化): CQRS+ES、フロントエンド、セキュリティ、QA レビューと修正ループ。 | | `magi` | エヴァンゲリオンにインスパイアされた審議システム。3つの AI ペルソナ(MELCHIOR、BALTHASAR、CASPER)が分析し投票。 | -| `coding` | 軽量開発ピース: architect-planner → 実装 → 並列レビュー(AI アンチパターン+アーキテクチャ)→ 修正。スーパーバイザーなしの高速フィードバックループ。 | +| `coding` | 軽量開発ピース: planner → 実装 → 並列レビュー(AI アンチパターン+アーキテクチャ)→ 修正。スーパーバイザーなしの高速フィードバックループ。 | | `passthrough` | 最小構成。タスクをそのまま coder に渡す薄いラッパー。レビューなし。 | +| `compound-eye` | マルチモデルレビュー: Claude と Codex に同じ指示を同時送信し、両方の回答を統合。 | | `review-only` | 変更を加えない読み取り専用のコードレビューピース。 | **Hybrid Codex バリアント** (`*-hybrid-codex`): 主要ピースごとに、coder エージェントを Codex で実行しレビュアーは Claude を使うハイブリッド構成が用意されています。対象: default, minimal, expert, expert-cqrs, passthrough, review-fix-minimal, coding。 @@ -462,6 +471,7 @@ TAKTには複数のビルトインピースが同梱されています: | **research-planner** | リサーチタスクの計画・スコープ定義 | | **research-digger** | 深掘り調査と情報収集 | | **research-supervisor** | リサーチ品質の検証と網羅性の評価 | +| **pr-commenter** | レビュー結果を GitHub PR にコメントとして投稿 | ## カスタムペルソナ @@ -527,6 +537,9 @@ provider: claude # デフォルトプロバイダー: claude または c model: sonnet # デフォルトモデル(オプション) branch_name_strategy: romaji # ブランチ名生成: 'romaji'(高速)または 'ai'(低速) prevent_sleep: false # macOS の実行中スリープ防止(caffeinate) +notification_sound: true # 通知音の有効/無効 +concurrency: 1 # takt run の並列タスク数(1-10、デフォルト: 1 = 逐次実行) +interactive_preview_movements: 3 # 対話モードでのムーブメントプレビュー数(0-10、デフォルト: 3) # API Key 設定(オプション) # 環境変数 TAKT_ANTHROPIC_API_KEY / TAKT_OPENAI_API_KEY で上書き可能 @@ -742,6 +755,7 @@ rules: | `permission_mode` | - | パーミッションモード: `readonly`、`edit`、`full`(プロバイダー非依存) | | `output_contracts` | - | レポートファイルの出力契約定義 | | `quality_gates` | - | ムーブメント完了要件のAIディレクティブ | +| `mcp_servers` | - | MCP(Model Context Protocol)サーバー設定(stdio/SSE/HTTP) | ## API使用例 diff --git a/package-lock.json b/package-lock.json index da99bef..f0ab485 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "takt", - "version": "0.8.0", + "version": "0.9.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "takt", - "version": "0.8.0", + "version": "0.9.0", "license": "MIT", "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.37", diff --git a/package.json b/package.json index fb6b109..46bd391 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "takt", - "version": "0.8.0", + "version": "0.9.0", "description": "TAKT: Task Agent Koordination Tool - AI Agent Piece Orchestration", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/src/__tests__/runAllTasks-concurrency.test.ts b/src/__tests__/runAllTasks-concurrency.test.ts index 258890b..02bdd5a 100644 --- a/src/__tests__/runAllTasks-concurrency.test.ts +++ b/src/__tests__/runAllTasks-concurrency.test.ts @@ -14,6 +14,7 @@ vi.mock('../infra/config/index.js', () => ({ defaultPiece: 'default', logLevel: 'info', concurrency: 1, + taskPollIntervalMs: 500, })), })); @@ -142,6 +143,7 @@ describe('runAllTasks concurrency', () => { defaultPiece: 'default', logLevel: 'info', concurrency: 1, + taskPollIntervalMs: 500, }); }); @@ -182,6 +184,7 @@ describe('runAllTasks concurrency', () => { defaultPiece: 'default', logLevel: 'info', concurrency: 3, + taskPollIntervalMs: 500, }); }); @@ -245,6 +248,7 @@ describe('runAllTasks concurrency', () => { defaultPiece: 'default', logLevel: 'info', concurrency: 1, + taskPollIntervalMs: 500, }); const task1 = createTask('task-1'); @@ -277,6 +281,7 @@ describe('runAllTasks concurrency', () => { defaultPiece: 'default', logLevel: 'info', concurrency: 3, + taskPollIntervalMs: 500, }); // Return a valid piece config so executeTask reaches executePiece mockLoadPieceByIdentifier.mockReturnValue(fakePieceConfig as never); @@ -323,6 +328,7 @@ describe('runAllTasks concurrency', () => { defaultPiece: 'default', logLevel: 'info', concurrency: 2, + taskPollIntervalMs: 500, }); const task1 = createTask('fast'); @@ -412,6 +418,7 @@ describe('runAllTasks concurrency', () => { defaultPiece: 'default', logLevel: 'info', concurrency: 1, + taskPollIntervalMs: 500, }); const task1 = createTask('sequential-task'); diff --git a/src/__tests__/saveTaskFile.test.ts b/src/__tests__/saveTaskFile.test.ts index 5111656..9d69a29 100644 --- a/src/__tests__/saveTaskFile.test.ts +++ b/src/__tests__/saveTaskFile.test.ts @@ -17,6 +17,11 @@ vi.mock('../shared/ui/index.js', () => ({ blankLine: vi.fn(), })); +vi.mock('../shared/prompt/index.js', () => ({ + confirm: vi.fn(), + promptInput: vi.fn(), +})); + vi.mock('../shared/utils/index.js', async (importOriginal) => ({ ...(await importOriginal>()), createLogger: () => ({ @@ -28,11 +33,14 @@ vi.mock('../shared/utils/index.js', async (importOriginal) => ({ import { summarizeTaskName } from '../infra/task/summarize.js'; import { success, info } from '../shared/ui/index.js'; +import { confirm, promptInput } from '../shared/prompt/index.js'; import { saveTaskFile, saveTaskFromInteractive } from '../features/tasks/add/index.js'; const mockSummarizeTaskName = vi.mocked(summarizeTaskName); const mockSuccess = vi.mocked(success); const mockInfo = vi.mocked(info); +const mockConfirm = vi.mocked(confirm); +const mockPromptInput = vi.mocked(promptInput); let testDir: string; @@ -163,16 +171,82 @@ describe('saveTaskFile', () => { }); describe('saveTaskFromInteractive', () => { - it('should save task and display success message', async () => { + it('should save task with worktree settings when user confirms worktree', async () => { + // Given: user confirms worktree, accepts defaults, confirms auto-PR + mockConfirm.mockResolvedValueOnce(true); // Create worktree? → Yes + mockPromptInput.mockResolvedValueOnce(''); // Worktree path → auto + mockPromptInput.mockResolvedValueOnce(''); // Branch name → auto + mockConfirm.mockResolvedValueOnce(true); // Auto-create PR? → Yes + // When await saveTaskFromInteractive(testDir, 'Task content'); // Then expect(mockSuccess).toHaveBeenCalledWith('Task created: test-task.yaml'); expect(mockInfo).toHaveBeenCalledWith(expect.stringContaining('Path:')); + const tasksDir = path.join(testDir, '.takt', 'tasks'); + const files = fs.readdirSync(tasksDir); + const content = fs.readFileSync(path.join(tasksDir, files[0]!), 'utf-8'); + expect(content).toContain('worktree: true'); + expect(content).toContain('auto_pr: true'); + }); + + it('should save task without worktree settings when user declines worktree', async () => { + // Given: user declines worktree + mockConfirm.mockResolvedValueOnce(false); // Create worktree? → No + + // When + await saveTaskFromInteractive(testDir, 'Task content'); + + // Then + expect(mockSuccess).toHaveBeenCalledWith('Task created: test-task.yaml'); + const tasksDir = path.join(testDir, '.takt', 'tasks'); + const files = fs.readdirSync(tasksDir); + const content = fs.readFileSync(path.join(tasksDir, files[0]!), 'utf-8'); + expect(content).not.toContain('worktree:'); + expect(content).not.toContain('branch:'); + expect(content).not.toContain('auto_pr:'); + }); + + it('should save custom worktree path and branch when specified', async () => { + // Given: user specifies custom path and branch + mockConfirm.mockResolvedValueOnce(true); // Create worktree? → Yes + mockPromptInput.mockResolvedValueOnce('/custom/path'); // Worktree path + mockPromptInput.mockResolvedValueOnce('feat/branch'); // Branch name + mockConfirm.mockResolvedValueOnce(false); // Auto-create PR? → No + + // When + await saveTaskFromInteractive(testDir, 'Task content'); + + // Then + const tasksDir = path.join(testDir, '.takt', 'tasks'); + const files = fs.readdirSync(tasksDir); + const content = fs.readFileSync(path.join(tasksDir, files[0]!), 'utf-8'); + expect(content).toContain('worktree: /custom/path'); + expect(content).toContain('branch: feat/branch'); + expect(content).toContain('auto_pr: false'); + }); + + it('should display worktree/branch/auto-PR info when settings are provided', async () => { + // Given + mockConfirm.mockResolvedValueOnce(true); // Create worktree? → Yes + mockPromptInput.mockResolvedValueOnce('/my/path'); // Worktree path + mockPromptInput.mockResolvedValueOnce('my-branch'); // Branch name + mockConfirm.mockResolvedValueOnce(true); // Auto-create PR? → Yes + + // When + await saveTaskFromInteractive(testDir, 'Task content'); + + // Then + expect(mockInfo).toHaveBeenCalledWith(' Worktree: /my/path'); + expect(mockInfo).toHaveBeenCalledWith(' Branch: my-branch'); + expect(mockInfo).toHaveBeenCalledWith(' Auto-PR: yes'); }); it('should display piece info when specified', async () => { + // Given + mockConfirm.mockResolvedValueOnce(false); // Create worktree? → No + // When await saveTaskFromInteractive(testDir, 'Task content', 'review'); @@ -181,6 +255,9 @@ describe('saveTaskFromInteractive', () => { }); it('should include piece in saved YAML', async () => { + // Given + mockConfirm.mockResolvedValueOnce(false); // Create worktree? → No + // When await saveTaskFromInteractive(testDir, 'Task content', 'custom'); @@ -193,6 +270,9 @@ describe('saveTaskFromInteractive', () => { }); it('should not display piece info when not specified', async () => { + // Given + mockConfirm.mockResolvedValueOnce(false); // Create worktree? → No + // When await saveTaskFromInteractive(testDir, 'Task content'); @@ -202,4 +282,18 @@ describe('saveTaskFromInteractive', () => { ); expect(pieceInfoCalls.length).toBe(0); }); + + it('should display auto worktree info when no custom path', async () => { + // Given + mockConfirm.mockResolvedValueOnce(true); // Create worktree? → Yes + mockPromptInput.mockResolvedValueOnce(''); // Worktree path → auto + mockPromptInput.mockResolvedValueOnce(''); // Branch name → auto + mockConfirm.mockResolvedValueOnce(true); // Auto-create PR? → Yes + + // When + await saveTaskFromInteractive(testDir, 'Task content'); + + // Then + expect(mockInfo).toHaveBeenCalledWith(' Worktree: auto'); + }); }); diff --git a/src/__tests__/sleep.test.ts b/src/__tests__/sleep.test.ts index e7e821b..cafb307 100644 --- a/src/__tests__/sleep.test.ts +++ b/src/__tests__/sleep.test.ts @@ -71,7 +71,7 @@ describe('preventSleep', () => { expect(spawn).toHaveBeenCalledWith( '/usr/bin/caffeinate', - ['-i', '-w', String(process.pid)], + ['-di', '-w', String(process.pid)], { stdio: 'ignore', detached: true } ); expect(mockChild.unref).toHaveBeenCalled(); diff --git a/src/__tests__/workerPool.test.ts b/src/__tests__/workerPool.test.ts index 4624d18..546eaa5 100644 --- a/src/__tests__/workerPool.test.ts +++ b/src/__tests__/workerPool.test.ts @@ -23,6 +23,15 @@ vi.mock('../shared/i18n/index.js', () => ({ getLabel: vi.fn((key: string) => key), })); +vi.mock('../shared/utils/index.js', async (importOriginal) => ({ + ...(await importOriginal>()), + createLogger: () => ({ + info: vi.fn(), + debug: vi.fn(), + error: vi.fn(), + }), +})); + const mockExecuteAndCompleteTask = vi.fn(); vi.mock('../features/tasks/execute/taskExecution.js', () => ({ @@ -34,6 +43,8 @@ import { info } from '../shared/ui/index.js'; const mockInfo = vi.mocked(info); +const TEST_POLL_INTERVAL_MS = 50; + function createTask(name: string): TaskInfo { return { name, @@ -68,7 +79,7 @@ describe('runWithWorkerPool', () => { const runner = createMockTaskRunner([]); // When - const result = await runWithWorkerPool(runner as never, tasks, 2, '/cwd', 'default'); + const result = await runWithWorkerPool(runner as never, tasks, 2, '/cwd', 'default', undefined, TEST_POLL_INTERVAL_MS); // Then expect(result).toEqual({ success: 2, fail: 0 }); @@ -85,7 +96,7 @@ describe('runWithWorkerPool', () => { const runner = createMockTaskRunner([]); // When - const result = await runWithWorkerPool(runner as never, tasks, 3, '/cwd', 'default'); + const result = await runWithWorkerPool(runner as never, tasks, 3, '/cwd', 'default', undefined, TEST_POLL_INTERVAL_MS); // Then expect(result).toEqual({ success: 2, fail: 1 }); @@ -97,7 +108,7 @@ describe('runWithWorkerPool', () => { const runner = createMockTaskRunner([]); // When - await runWithWorkerPool(runner as never, tasks, 2, '/cwd', 'default'); + await runWithWorkerPool(runner as never, tasks, 2, '/cwd', 'default', undefined, TEST_POLL_INTERVAL_MS); // Then expect(mockInfo).toHaveBeenCalledWith('=== Task: alpha ==='); @@ -110,7 +121,7 @@ describe('runWithWorkerPool', () => { const runner = createMockTaskRunner([]); // When - await runWithWorkerPool(runner as never, tasks, 2, '/cwd', 'default'); + await runWithWorkerPool(runner as never, tasks, 2, '/cwd', 'default', undefined, TEST_POLL_INTERVAL_MS); // Then expect(mockExecuteAndCompleteTask).toHaveBeenCalledTimes(1); @@ -127,7 +138,7 @@ describe('runWithWorkerPool', () => { const runner = createMockTaskRunner([]); // When - await runWithWorkerPool(runner as never, tasks, 1, '/cwd', 'default'); + await runWithWorkerPool(runner as never, tasks, 1, '/cwd', 'default', undefined, TEST_POLL_INTERVAL_MS); // Then expect(mockExecuteAndCompleteTask).toHaveBeenCalledTimes(1); @@ -145,7 +156,7 @@ describe('runWithWorkerPool', () => { const runner = createMockTaskRunner([[task2]]); // When - await runWithWorkerPool(runner as never, [task1], 2, '/cwd', 'default'); + await runWithWorkerPool(runner as never, [task1], 2, '/cwd', 'default', undefined, TEST_POLL_INTERVAL_MS); // Then expect(mockExecuteAndCompleteTask).toHaveBeenCalledTimes(2); @@ -173,7 +184,7 @@ describe('runWithWorkerPool', () => { const runner = createMockTaskRunner([]); // When - await runWithWorkerPool(runner as never, tasks, 2, '/cwd', 'default'); + await runWithWorkerPool(runner as never, tasks, 2, '/cwd', 'default', undefined, TEST_POLL_INTERVAL_MS); // Then: Never exceeded concurrency of 2 expect(maxActive).toBeLessThanOrEqual(2); @@ -192,7 +203,7 @@ describe('runWithWorkerPool', () => { }); // When - await runWithWorkerPool(runner as never, tasks, 3, '/cwd', 'default'); + await runWithWorkerPool(runner as never, tasks, 3, '/cwd', 'default', undefined, TEST_POLL_INTERVAL_MS); // Then: All tasks received the same AbortSignal expect(receivedSignals).toHaveLength(3); @@ -208,7 +219,7 @@ describe('runWithWorkerPool', () => { const runner = createMockTaskRunner([]); // When - const result = await runWithWorkerPool(runner as never, [], 2, '/cwd', 'default'); + const result = await runWithWorkerPool(runner as never, [], 2, '/cwd', 'default', undefined, TEST_POLL_INTERVAL_MS); // Then expect(result).toEqual({ success: 0, fail: 0 }); @@ -222,9 +233,107 @@ describe('runWithWorkerPool', () => { const runner = createMockTaskRunner([]); // When - const result = await runWithWorkerPool(runner as never, tasks, 1, '/cwd', 'default'); + const result = await runWithWorkerPool(runner as never, tasks, 1, '/cwd', 'default', undefined, TEST_POLL_INTERVAL_MS); // Then: Treated as failure expect(result).toEqual({ success: 0, fail: 1 }); }); + + describe('polling', () => { + it('should pick up tasks added during execution via polling', async () => { + // Given: 1 initial task running with concurrency=2, a second task appears via poll + const task1 = createTask('initial'); + const task2 = createTask('added-later'); + + const executionOrder: string[] = []; + + mockExecuteAndCompleteTask.mockImplementation((task: TaskInfo) => { + executionOrder.push(`start:${task.name}`); + return new Promise((resolve) => { + setTimeout(() => { + executionOrder.push(`end:${task.name}`); + resolve(true); + }, 80); + }); + }); + + let claimCallCount = 0; + const runner = { + getNextTask: vi.fn(() => null), + claimNextTasks: vi.fn(() => { + claimCallCount++; + // Return the new task on the second call (triggered by polling) + if (claimCallCount === 2) return [task2]; + return []; + }), + completeTask: vi.fn(), + failTask: vi.fn(), + }; + + // When: pollIntervalMs=30 so polling fires before task1 completes (80ms) + const result = await runWithWorkerPool( + runner as never, [task1], 2, '/cwd', 'default', undefined, 30, + ); + + // Then: Both tasks were executed + expect(result).toEqual({ success: 2, fail: 0 }); + expect(executionOrder).toContain('start:initial'); + expect(executionOrder).toContain('start:added-later'); + // task2 started before task1 ended (picked up by polling, not by task completion) + const task2Start = executionOrder.indexOf('start:added-later'); + const task1End = executionOrder.indexOf('end:initial'); + expect(task2Start).toBeLessThan(task1End); + }); + + it('should work correctly with concurrency=1 (sequential behavior preserved)', async () => { + // Given: concurrency=1, tasks claimed sequentially + const task1 = createTask('seq-1'); + const task2 = createTask('seq-2'); + + const executionOrder: string[] = []; + mockExecuteAndCompleteTask.mockImplementation((task: TaskInfo) => { + executionOrder.push(`start:${task.name}`); + return new Promise((resolve) => { + setTimeout(() => { + executionOrder.push(`end:${task.name}`); + resolve(true); + }, 20); + }); + }); + + const runner = createMockTaskRunner([[task2]]); + + // When + const result = await runWithWorkerPool( + runner as never, [task1], 1, '/cwd', 'default', undefined, TEST_POLL_INTERVAL_MS, + ); + + // Then: Tasks executed sequentially — task2 starts after task1 ends + expect(result).toEqual({ success: 2, fail: 0 }); + const task2Start = executionOrder.indexOf('start:seq-2'); + const task1End = executionOrder.indexOf('end:seq-1'); + expect(task2Start).toBeGreaterThan(task1End); + }); + + it('should not leak poll timer when task completes before poll fires', async () => { + // Given: A task that completes in 200ms, poll interval is 5000ms + const task1 = createTask('fast-task'); + + mockExecuteAndCompleteTask.mockImplementation(() => { + return new Promise((resolve) => { + setTimeout(() => resolve(true), 200); + }); + }); + + const runner = createMockTaskRunner([]); + + // When: Task completes before poll timer fires; cancel() cleans up timer + const result = await runWithWorkerPool( + runner as never, [task1], 1, '/cwd', 'default', undefined, 5000, + ); + + // Then: Result is returned without hanging (timer was cleaned up by cancel()) + expect(result).toEqual({ success: 1, fail: 0 }); + }); + }); }); diff --git a/src/core/models/global-config.ts b/src/core/models/global-config.ts index 4ca9f75..501d23e 100644 --- a/src/core/models/global-config.ts +++ b/src/core/models/global-config.ts @@ -71,6 +71,8 @@ export interface GlobalConfig { interactivePreviewMovements?: number; /** Number of tasks to run concurrently in takt run (default: 1 = sequential) */ concurrency: number; + /** Polling interval in ms for picking up new tasks during takt run (default: 500, range: 100-5000) */ + taskPollIntervalMs: number; } /** Project-level configuration */ diff --git a/src/core/models/schemas.ts b/src/core/models/schemas.ts index 7ca1f7f..cd39906 100644 --- a/src/core/models/schemas.ts +++ b/src/core/models/schemas.ts @@ -322,6 +322,8 @@ export const GlobalConfigSchema = z.object({ interactive_preview_movements: z.number().int().min(0).max(10).optional().default(3), /** Number of tasks to run concurrently in takt run (default: 1 = sequential, max: 10) */ concurrency: z.number().int().min(1).max(10).optional().default(1), + /** Polling interval in ms for picking up new tasks during takt run (default: 500, range: 100-5000) */ + task_poll_interval_ms: z.number().int().min(100).max(5000).optional().default(500), }); /** Project config schema */ diff --git a/src/features/tasks/add/index.ts b/src/features/tasks/add/index.ts index 6da95e3..6e9e37a 100644 --- a/src/features/tasks/add/index.ts +++ b/src/features/tasks/add/index.ts @@ -87,19 +87,52 @@ export function createIssueFromTask(task: string): void { } } +interface WorktreeSettings { + worktree?: boolean | string; + branch?: string; + autoPr?: boolean; +} + +async function promptWorktreeSettings(): Promise { + const useWorktree = await confirm('Create worktree?', true); + if (!useWorktree) { + return {}; + } + + const customPath = await promptInput('Worktree path (Enter for auto)'); + const worktree: boolean | string = customPath || true; + + const customBranch = await promptInput('Branch name (Enter for auto)'); + const branch = customBranch || undefined; + + const autoPr = await confirm('Auto-create PR?', true); + + return { worktree, branch, autoPr }; +} + /** * Save a task from interactive mode result. - * Does not prompt for worktree/branch settings. + * Prompts for worktree/branch/auto_pr settings before saving. */ export async function saveTaskFromInteractive( cwd: string, task: string, piece?: string, ): Promise { - const filePath = await saveTaskFile(cwd, task, { piece }); + const settings = await promptWorktreeSettings(); + const filePath = await saveTaskFile(cwd, task, { piece, ...settings }); const filename = path.basename(filePath); success(`Task created: ${filename}`); info(` Path: ${filePath}`); + if (settings.worktree) { + info(` Worktree: ${typeof settings.worktree === 'string' ? settings.worktree : 'auto'}`); + } + if (settings.branch) { + info(` Branch: ${settings.branch}`); + } + if (settings.autoPr) { + info(` Auto-PR: yes`); + } if (piece) info(` Piece: ${piece}`); } @@ -173,43 +206,25 @@ export async function addTask(cwd: string, task?: string): Promise { } // 3. ワークツリー/ブランチ/PR設定 - let worktree: boolean | string | undefined; - let branch: string | undefined; - let autoPr: boolean | undefined; - - const useWorktree = await confirm('Create worktree?', true); - if (useWorktree) { - const customPath = await promptInput('Worktree path (Enter for auto)'); - worktree = customPath || true; - - const customBranch = await promptInput('Branch name (Enter for auto)'); - if (customBranch) { - branch = customBranch; - } - - // PR確認(worktreeが有効な場合のみ) - autoPr = await confirm('Auto-create PR?', true); - } + const settings = await promptWorktreeSettings(); // YAMLファイル作成 const filePath = await saveTaskFile(cwd, taskContent, { piece, issue: issueNumber, - worktree, - branch, - autoPr, + ...settings, }); const filename = path.basename(filePath); success(`Task created: ${filename}`); info(` Path: ${filePath}`); - if (worktree) { - info(` Worktree: ${typeof worktree === 'string' ? worktree : 'auto'}`); + if (settings.worktree) { + info(` Worktree: ${typeof settings.worktree === 'string' ? settings.worktree : 'auto'}`); } - if (branch) { - info(` Branch: ${branch}`); + if (settings.branch) { + info(` Branch: ${settings.branch}`); } - if (autoPr) { + if (settings.autoPr) { info(` Auto-PR: yes`); } if (piece) { diff --git a/src/features/tasks/execute/parallelExecution.ts b/src/features/tasks/execute/parallelExecution.ts index 67130ef..12e4ad7 100644 --- a/src/features/tasks/execute/parallelExecution.ts +++ b/src/features/tasks/execute/parallelExecution.ts @@ -5,19 +5,74 @@ * available task as soon as it finishes the current one, maximizing slot * utilization. Works for both sequential (concurrency=1) and parallel * (concurrency>1) execution through the same code path. + * + * Polls for newly added tasks at a configurable interval so that tasks + * added to .takt/tasks/ during execution are picked up without waiting + * for an active task to complete. */ import type { TaskRunner, TaskInfo } from '../../../infra/task/index.js'; import { info, blankLine } from '../../../shared/ui/index.js'; +import { createLogger } from '../../../shared/utils/index.js'; import { executeAndCompleteTask } from './taskExecution.js'; import { installSigIntHandler } from './sigintHandler.js'; import type { TaskExecutionOptions } from './types.js'; +const log = createLogger('worker-pool'); + export interface WorkerPoolResult { success: number; fail: number; } +type RaceResult = + | { type: 'completion'; promise: Promise; result: boolean } + | { type: 'poll' }; + +interface PollTimer { + promise: Promise; + cancel: () => void; +} + +function createPollTimer(intervalMs: number, signal: AbortSignal): PollTimer { + let timeoutId: ReturnType | undefined; + let onAbort: (() => void) | undefined; + + const promise = new Promise((resolve) => { + if (signal.aborted) { + resolve({ type: 'poll' }); + return; + } + + onAbort = () => { + if (timeoutId !== undefined) clearTimeout(timeoutId); + resolve({ type: 'poll' }); + }; + + timeoutId = setTimeout(() => { + signal.removeEventListener('abort', onAbort!); + onAbort = undefined; + resolve({ type: 'poll' }); + }, intervalMs); + + signal.addEventListener('abort', onAbort, { once: true }); + }); + + return { + promise, + cancel: () => { + if (timeoutId !== undefined) { + clearTimeout(timeoutId); + timeoutId = undefined; + } + if (onAbort) { + signal.removeEventListener('abort', onAbort); + onAbort = undefined; + } + }, + }; +} + /** * Run tasks using a worker pool with the given concurrency. * @@ -25,9 +80,10 @@ export interface WorkerPoolResult { * 1. Create a shared AbortController * 2. Maintain a queue of pending tasks and a set of active promises * 3. Fill available slots from the queue - * 4. Wait for any active task to complete (Promise.race) - * 5. Record result, fill freed slot from queue - * 6. Repeat until queue is empty and all active tasks complete + * 4. Wait for any active task to complete OR a poll timer to fire (Promise.race) + * 5. On task completion: record result + * 6. On poll tick or completion: claim new tasks and fill freed slots + * 7. Repeat until queue is empty and all active tasks complete */ export async function runWithWorkerPool( taskRunner: TaskRunner, @@ -35,7 +91,8 @@ export async function runWithWorkerPool( concurrency: number, cwd: string, pieceName: string, - options?: TaskExecutionOptions, + options: TaskExecutionOptions | undefined, + pollIntervalMs: number, ): Promise { const abortController = new AbortController(); const { cleanup } = installSigIntHandler(() => abortController.abort()); @@ -58,27 +115,44 @@ export async function runWithWorkerPool( break; } - const settled = await Promise.race( - [...active.keys()].map((p) => p.then( - (result) => ({ promise: p, result }), - () => ({ promise: p, result: false }), - )), + const pollTimer = createPollTimer(pollIntervalMs, abortController.signal); + + const completionPromises: Promise[] = [...active.keys()].map((p) => + p.then( + (result): RaceResult => ({ type: 'completion', promise: p, result }), + (): RaceResult => ({ type: 'completion', promise: p, result: false }), + ), ); - const task = active.get(settled.promise); - active.delete(settled.promise); + const settled = await Promise.race([...completionPromises, pollTimer.promise]); - if (task) { - if (settled.result) { - successCount++; - } else { - failCount++; + pollTimer.cancel(); + + if (settled.type === 'completion') { + const task = active.get(settled.promise); + active.delete(settled.promise); + + if (task) { + if (settled.result) { + successCount++; + } else { + failCount++; + } } } - if (!abortController.signal.aborted && queue.length === 0) { - const nextTasks = taskRunner.claimNextTasks(concurrency - active.size); - queue.push(...nextTasks); + if (!abortController.signal.aborted) { + const freeSlots = concurrency - active.size; + if (freeSlots > 0) { + const newTasks = taskRunner.claimNextTasks(freeSlots); + log.debug('poll_tick', { active: active.size, queued: queue.length, freeSlots }); + if (newTasks.length > 0) { + log.debug('poll_new_tasks', { count: newTasks.length }); + queue.push(...newTasks); + } else { + log.debug('no_new_tasks'); + } + } } } } finally { diff --git a/src/features/tasks/execute/taskExecution.ts b/src/features/tasks/execute/taskExecution.ts index b385a5a..66796bd 100644 --- a/src/features/tasks/execute/taskExecution.ts +++ b/src/features/tasks/execute/taskExecution.ts @@ -220,7 +220,7 @@ export async function runAllTasks( info(`Concurrency: ${concurrency}`); } - const result = await runWithWorkerPool(taskRunner, initialTasks, concurrency, cwd, pieceName, options); + const result = await runWithWorkerPool(taskRunner, initialTasks, concurrency, cwd, pieceName, options, globalConfig.taskPollIntervalMs); const totalCount = result.success + result.fail; blankLine(); diff --git a/src/infra/config/global/globalConfig.ts b/src/infra/config/global/globalConfig.ts index 1f8aa00..0febec4 100644 --- a/src/infra/config/global/globalConfig.ts +++ b/src/infra/config/global/globalConfig.ts @@ -37,6 +37,7 @@ function createDefaultGlobalConfig(): GlobalConfig { enableBuiltinPieces: true, interactivePreviewMovements: 3, concurrency: 1, + taskPollIntervalMs: 500, }; } @@ -110,6 +111,7 @@ export class GlobalConfigManager { notificationSound: parsed.notification_sound, interactivePreviewMovements: parsed.interactive_preview_movements, concurrency: parsed.concurrency, + taskPollIntervalMs: parsed.task_poll_interval_ms, }; validateProviderModelCompatibility(config.provider, config.model); this.cachedConfig = config; @@ -185,6 +187,9 @@ export class GlobalConfigManager { if (config.concurrency !== undefined && config.concurrency > 1) { raw.concurrency = config.concurrency; } + if (config.taskPollIntervalMs !== undefined && config.taskPollIntervalMs !== 500) { + raw.task_poll_interval_ms = config.taskPollIntervalMs; + } writeFileSync(configPath, stringifyYaml(raw), 'utf-8'); this.invalidateCache(); } diff --git a/src/shared/utils/sleep.ts b/src/shared/utils/sleep.ts index 0c2ec6d..5e8164c 100644 --- a/src/shared/utils/sleep.ts +++ b/src/shared/utils/sleep.ts @@ -8,7 +8,9 @@ const log = createLogger('sleep'); let caffeinateStarted = false; /** - * takt実行中のmacOSアイドルスリープを防止する。 + * takt実行中のmacOSアイドルスリープおよびディスプレイスリープを防止する。 + * -d: ディスプレイスリープ防止(App Nap によるプロセス凍結を回避) + * -i: アイドルスリープ防止 * 蓋を閉じた場合のスリープは防げない(-s はAC電源が必要なため)。 */ export function preventSleep(): void { @@ -26,7 +28,7 @@ export function preventSleep(): void { return; } - const child = spawn(caffeinatePath, ['-i', '-w', String(process.pid)], { + const child = spawn(caffeinatePath, ['-di', '-w', String(process.pid)], { stdio: 'ignore', detached: true, });