github-issue-157-takt-run-ni-p (#160)

* 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
This commit is contained in:
nrs 2026-02-09 00:17:47 +09:00 committed by GitHub
parent 3533946602
commit cdedb4326e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 441 additions and 68 deletions

View File

@ -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 の内容を正式リリース。機能変更なし。

View File

@ -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

View File

@ -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` | - | MCPModel Context Protocolサーバー設定stdio/SSE/HTTP |
## API使用例

4
package-lock.json generated
View File

@ -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",

View File

@ -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",

View File

@ -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');

View File

@ -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<Record<string, unknown>>()),
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');
});
});

View File

@ -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();

View File

@ -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<Record<string, unknown>>()),
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 });
});
});
});

View File

@ -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 */

View File

@ -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 */

View File

@ -87,19 +87,52 @@ export function createIssueFromTask(task: string): void {
}
}
interface WorktreeSettings {
worktree?: boolean | string;
branch?: string;
autoPr?: boolean;
}
async function promptWorktreeSettings(): Promise<WorktreeSettings> {
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<void> {
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<void> {
}
// 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) {

View File

@ -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<boolean>; result: boolean }
| { type: 'poll' };
interface PollTimer {
promise: Promise<RaceResult>;
cancel: () => void;
}
function createPollTimer(intervalMs: number, signal: AbortSignal): PollTimer {
let timeoutId: ReturnType<typeof setTimeout> | undefined;
let onAbort: (() => void) | undefined;
const promise = new Promise<RaceResult>((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<WorkerPoolResult> {
const abortController = new AbortController();
const { cleanup } = installSigIntHandler(() => abortController.abort());
@ -58,13 +115,20 @@ 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<RaceResult>[] = [...active.keys()].map((p) =>
p.then(
(result): RaceResult => ({ type: 'completion', promise: p, result }),
(): RaceResult => ({ type: 'completion', promise: p, result: false }),
),
);
const settled = await Promise.race([...completionPromises, pollTimer.promise]);
pollTimer.cancel();
if (settled.type === 'completion') {
const task = active.get(settled.promise);
active.delete(settled.promise);
@ -75,10 +139,20 @@ export async function runWithWorkerPool(
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 {

View File

@ -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();

View File

@ -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();
}

View File

@ -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,
});