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:
parent
3533946602
commit
cdedb4326e
35
CHANGELOG.md
35
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/).
|
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
|
## [0.8.0] - 2026-02-08
|
||||||
|
|
||||||
alpha.1 の内容を正式リリース。機能変更なし。
|
alpha.1 の内容を正式リリース。機能変更なし。
|
||||||
|
|||||||
18
README.md
18
README.md
@ -262,6 +262,14 @@ takt clear
|
|||||||
# Deploy builtin pieces/personas as Claude Code Skill
|
# Deploy builtin pieces/personas as Claude Code Skill
|
||||||
takt export-cc
|
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
|
# Preview assembled prompts for each movement and phase
|
||||||
takt prompt [piece]
|
takt prompt [piece]
|
||||||
|
|
||||||
@ -432,15 +440,16 @@ TAKT includes multiple builtin pieces:
|
|||||||
|
|
||||||
| Piece | Description |
|
| 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. |
|
| `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. |
|
| `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. |
|
| `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` | 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. |
|
| `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. |
|
| `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. |
|
| `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. |
|
| `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.
|
**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-planner** | Research task planning and scope definition |
|
||||||
| **research-digger** | Deep investigation and information gathering |
|
| **research-digger** | Deep investigation and information gathering |
|
||||||
| **research-supervisor** | Research quality validation and completeness assessment |
|
| **research-supervisor** | Research quality validation and completeness assessment |
|
||||||
|
| **pr-commenter** | Posts review findings as GitHub PR comments |
|
||||||
|
|
||||||
## Custom Personas
|
## Custom Personas
|
||||||
|
|
||||||
@ -531,6 +541,9 @@ provider: claude # Default provider: claude or codex
|
|||||||
model: sonnet # Default model (optional)
|
model: sonnet # Default model (optional)
|
||||||
branch_name_strategy: romaji # Branch name generation: 'romaji' (fast) or 'ai' (slow)
|
branch_name_strategy: romaji # Branch name generation: 'romaji' (fast) or 'ai' (slow)
|
||||||
prevent_sleep: false # Prevent macOS idle sleep during execution (caffeinate)
|
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)
|
# API Key configuration (optional)
|
||||||
# Can be overridden by environment variables TAKT_ANTHROPIC_API_KEY / TAKT_OPENAI_API_KEY
|
# 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) |
|
| `permission_mode` | - | Permission mode: `readonly`, `edit`, `full` (provider-independent) |
|
||||||
| `output_contracts` | - | Output contract definitions for report files |
|
| `output_contracts` | - | Output contract definitions for report files |
|
||||||
| `quality_gates` | - | AI directives for movement completion requirements |
|
| `quality_gates` | - | AI directives for movement completion requirements |
|
||||||
|
| `mcp_servers` | - | MCP (Model Context Protocol) server configuration (stdio/SSE/HTTP) |
|
||||||
|
|
||||||
## API Usage Example
|
## API Usage Example
|
||||||
|
|
||||||
|
|||||||
@ -258,6 +258,14 @@ takt clear
|
|||||||
# ビルトインピース・エージェントを Claude Code Skill としてデプロイ
|
# ビルトインピース・エージェントを Claude Code Skill としてデプロイ
|
||||||
takt export-cc
|
takt export-cc
|
||||||
|
|
||||||
|
# 利用可能なファセットをレイヤー別に一覧表示
|
||||||
|
takt catalog
|
||||||
|
takt catalog personas
|
||||||
|
|
||||||
|
# 特定のファセットをカスタマイズ用にコピー
|
||||||
|
takt eject persona coder
|
||||||
|
takt eject instruction plan --global
|
||||||
|
|
||||||
# 各ムーブメント・フェーズの組み立て済みプロンプトをプレビュー
|
# 各ムーブメント・フェーズの組み立て済みプロンプトをプレビュー
|
||||||
takt prompt [piece]
|
takt prompt [piece]
|
||||||
|
|
||||||
@ -428,15 +436,16 @@ TAKTには複数のビルトインピースが同梱されています:
|
|||||||
|
|
||||||
| ピース | 説明 |
|
| ピース | 説明 |
|
||||||
|------------|------|
|
|------------|------|
|
||||||
| `default` | フル開発ピース: 計画 → アーキテクチャ設計 → 実装 → AI レビュー → 並列レビュー(アーキテクト+セキュリティ)→ スーパーバイザー承認。各レビュー段階に修正ループあり。 |
|
| `default` | フル開発ピース: 計画 → 実装 → AI レビュー → 並列レビュー(アーキテクト+QA)→ スーパーバイザー承認。各レビュー段階に修正ループあり。 |
|
||||||
| `minimal` | クイックピース: 計画 → 実装 → レビュー → スーパーバイザー。高速イテレーション向けの最小構成。 |
|
| `minimal` | クイックピース: 計画 → 実装 → レビュー → スーパーバイザー。高速イテレーション向けの最小構成。 |
|
||||||
| `review-fix-minimal` | レビュー重視ピース: レビュー → 修正 → スーパーバイザー。レビューフィードバックに基づく反復改善向け。 |
|
| `review-fix-minimal` | レビュー重視ピース: レビュー → 修正 → スーパーバイザー。レビューフィードバックに基づく反復改善向け。 |
|
||||||
| `research` | リサーチピース: プランナー → ディガー → スーパーバイザー。質問せずに自律的にリサーチを実行。 |
|
| `research` | リサーチピース: プランナー → ディガー → スーパーバイザー。質問せずに自律的にリサーチを実行。 |
|
||||||
| `expert` | フルスタック開発ピース: アーキテクチャ、フロントエンド、セキュリティ、QA レビューと修正ループ。 |
|
| `expert` | フルスタック開発ピース: アーキテクチャ、フロントエンド、セキュリティ、QA レビューと修正ループ。 |
|
||||||
| `expert-cqrs` | フルスタック開発ピース(CQRS+ES特化): CQRS+ES、フロントエンド、セキュリティ、QA レビューと修正ループ。 |
|
| `expert-cqrs` | フルスタック開発ピース(CQRS+ES特化): CQRS+ES、フロントエンド、セキュリティ、QA レビューと修正ループ。 |
|
||||||
| `magi` | エヴァンゲリオンにインスパイアされた審議システム。3つの AI ペルソナ(MELCHIOR、BALTHASAR、CASPER)が分析し投票。 |
|
| `magi` | エヴァンゲリオンにインスパイアされた審議システム。3つの AI ペルソナ(MELCHIOR、BALTHASAR、CASPER)が分析し投票。 |
|
||||||
| `coding` | 軽量開発ピース: architect-planner → 実装 → 並列レビュー(AI アンチパターン+アーキテクチャ)→ 修正。スーパーバイザーなしの高速フィードバックループ。 |
|
| `coding` | 軽量開発ピース: planner → 実装 → 並列レビュー(AI アンチパターン+アーキテクチャ)→ 修正。スーパーバイザーなしの高速フィードバックループ。 |
|
||||||
| `passthrough` | 最小構成。タスクをそのまま coder に渡す薄いラッパー。レビューなし。 |
|
| `passthrough` | 最小構成。タスクをそのまま coder に渡す薄いラッパー。レビューなし。 |
|
||||||
|
| `compound-eye` | マルチモデルレビュー: Claude と Codex に同じ指示を同時送信し、両方の回答を統合。 |
|
||||||
| `review-only` | 変更を加えない読み取り専用のコードレビューピース。 |
|
| `review-only` | 変更を加えない読み取り専用のコードレビューピース。 |
|
||||||
|
|
||||||
**Hybrid Codex バリアント** (`*-hybrid-codex`): 主要ピースごとに、coder エージェントを Codex で実行しレビュアーは Claude を使うハイブリッド構成が用意されています。対象: default, minimal, expert, expert-cqrs, passthrough, review-fix-minimal, coding。
|
**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-planner** | リサーチタスクの計画・スコープ定義 |
|
||||||
| **research-digger** | 深掘り調査と情報収集 |
|
| **research-digger** | 深掘り調査と情報収集 |
|
||||||
| **research-supervisor** | リサーチ品質の検証と網羅性の評価 |
|
| **research-supervisor** | リサーチ品質の検証と網羅性の評価 |
|
||||||
|
| **pr-commenter** | レビュー結果を GitHub PR にコメントとして投稿 |
|
||||||
|
|
||||||
## カスタムペルソナ
|
## カスタムペルソナ
|
||||||
|
|
||||||
@ -527,6 +537,9 @@ provider: claude # デフォルトプロバイダー: claude または c
|
|||||||
model: sonnet # デフォルトモデル(オプション)
|
model: sonnet # デフォルトモデル(オプション)
|
||||||
branch_name_strategy: romaji # ブランチ名生成: 'romaji'(高速)または 'ai'(低速)
|
branch_name_strategy: romaji # ブランチ名生成: 'romaji'(高速)または 'ai'(低速)
|
||||||
prevent_sleep: false # macOS の実行中スリープ防止(caffeinate)
|
prevent_sleep: false # macOS の実行中スリープ防止(caffeinate)
|
||||||
|
notification_sound: true # 通知音の有効/無効
|
||||||
|
concurrency: 1 # takt run の並列タスク数(1-10、デフォルト: 1 = 逐次実行)
|
||||||
|
interactive_preview_movements: 3 # 対話モードでのムーブメントプレビュー数(0-10、デフォルト: 3)
|
||||||
|
|
||||||
# API Key 設定(オプション)
|
# API Key 設定(オプション)
|
||||||
# 環境変数 TAKT_ANTHROPIC_API_KEY / TAKT_OPENAI_API_KEY で上書き可能
|
# 環境変数 TAKT_ANTHROPIC_API_KEY / TAKT_OPENAI_API_KEY で上書き可能
|
||||||
@ -742,6 +755,7 @@ rules:
|
|||||||
| `permission_mode` | - | パーミッションモード: `readonly`、`edit`、`full`(プロバイダー非依存) |
|
| `permission_mode` | - | パーミッションモード: `readonly`、`edit`、`full`(プロバイダー非依存) |
|
||||||
| `output_contracts` | - | レポートファイルの出力契約定義 |
|
| `output_contracts` | - | レポートファイルの出力契約定義 |
|
||||||
| `quality_gates` | - | ムーブメント完了要件のAIディレクティブ |
|
| `quality_gates` | - | ムーブメント完了要件のAIディレクティブ |
|
||||||
|
| `mcp_servers` | - | MCP(Model Context Protocol)サーバー設定(stdio/SSE/HTTP) |
|
||||||
|
|
||||||
## API使用例
|
## API使用例
|
||||||
|
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "takt",
|
"name": "takt",
|
||||||
"version": "0.8.0",
|
"version": "0.9.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "takt",
|
"name": "takt",
|
||||||
"version": "0.8.0",
|
"version": "0.9.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/claude-agent-sdk": "^0.2.37",
|
"@anthropic-ai/claude-agent-sdk": "^0.2.37",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "takt",
|
"name": "takt",
|
||||||
"version": "0.8.0",
|
"version": "0.9.0",
|
||||||
"description": "TAKT: Task Agent Koordination Tool - AI Agent Piece Orchestration",
|
"description": "TAKT: Task Agent Koordination Tool - AI Agent Piece Orchestration",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
|
|||||||
@ -14,6 +14,7 @@ vi.mock('../infra/config/index.js', () => ({
|
|||||||
defaultPiece: 'default',
|
defaultPiece: 'default',
|
||||||
logLevel: 'info',
|
logLevel: 'info',
|
||||||
concurrency: 1,
|
concurrency: 1,
|
||||||
|
taskPollIntervalMs: 500,
|
||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@ -142,6 +143,7 @@ describe('runAllTasks concurrency', () => {
|
|||||||
defaultPiece: 'default',
|
defaultPiece: 'default',
|
||||||
logLevel: 'info',
|
logLevel: 'info',
|
||||||
concurrency: 1,
|
concurrency: 1,
|
||||||
|
taskPollIntervalMs: 500,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -182,6 +184,7 @@ describe('runAllTasks concurrency', () => {
|
|||||||
defaultPiece: 'default',
|
defaultPiece: 'default',
|
||||||
logLevel: 'info',
|
logLevel: 'info',
|
||||||
concurrency: 3,
|
concurrency: 3,
|
||||||
|
taskPollIntervalMs: 500,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -245,6 +248,7 @@ describe('runAllTasks concurrency', () => {
|
|||||||
defaultPiece: 'default',
|
defaultPiece: 'default',
|
||||||
logLevel: 'info',
|
logLevel: 'info',
|
||||||
concurrency: 1,
|
concurrency: 1,
|
||||||
|
taskPollIntervalMs: 500,
|
||||||
});
|
});
|
||||||
|
|
||||||
const task1 = createTask('task-1');
|
const task1 = createTask('task-1');
|
||||||
@ -277,6 +281,7 @@ describe('runAllTasks concurrency', () => {
|
|||||||
defaultPiece: 'default',
|
defaultPiece: 'default',
|
||||||
logLevel: 'info',
|
logLevel: 'info',
|
||||||
concurrency: 3,
|
concurrency: 3,
|
||||||
|
taskPollIntervalMs: 500,
|
||||||
});
|
});
|
||||||
// Return a valid piece config so executeTask reaches executePiece
|
// Return a valid piece config so executeTask reaches executePiece
|
||||||
mockLoadPieceByIdentifier.mockReturnValue(fakePieceConfig as never);
|
mockLoadPieceByIdentifier.mockReturnValue(fakePieceConfig as never);
|
||||||
@ -323,6 +328,7 @@ describe('runAllTasks concurrency', () => {
|
|||||||
defaultPiece: 'default',
|
defaultPiece: 'default',
|
||||||
logLevel: 'info',
|
logLevel: 'info',
|
||||||
concurrency: 2,
|
concurrency: 2,
|
||||||
|
taskPollIntervalMs: 500,
|
||||||
});
|
});
|
||||||
|
|
||||||
const task1 = createTask('fast');
|
const task1 = createTask('fast');
|
||||||
@ -412,6 +418,7 @@ describe('runAllTasks concurrency', () => {
|
|||||||
defaultPiece: 'default',
|
defaultPiece: 'default',
|
||||||
logLevel: 'info',
|
logLevel: 'info',
|
||||||
concurrency: 1,
|
concurrency: 1,
|
||||||
|
taskPollIntervalMs: 500,
|
||||||
});
|
});
|
||||||
|
|
||||||
const task1 = createTask('sequential-task');
|
const task1 = createTask('sequential-task');
|
||||||
|
|||||||
@ -17,6 +17,11 @@ vi.mock('../shared/ui/index.js', () => ({
|
|||||||
blankLine: vi.fn(),
|
blankLine: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('../shared/prompt/index.js', () => ({
|
||||||
|
confirm: vi.fn(),
|
||||||
|
promptInput: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('../shared/utils/index.js', async (importOriginal) => ({
|
vi.mock('../shared/utils/index.js', async (importOriginal) => ({
|
||||||
...(await importOriginal<Record<string, unknown>>()),
|
...(await importOriginal<Record<string, unknown>>()),
|
||||||
createLogger: () => ({
|
createLogger: () => ({
|
||||||
@ -28,11 +33,14 @@ vi.mock('../shared/utils/index.js', async (importOriginal) => ({
|
|||||||
|
|
||||||
import { summarizeTaskName } from '../infra/task/summarize.js';
|
import { summarizeTaskName } from '../infra/task/summarize.js';
|
||||||
import { success, info } from '../shared/ui/index.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';
|
import { saveTaskFile, saveTaskFromInteractive } from '../features/tasks/add/index.js';
|
||||||
|
|
||||||
const mockSummarizeTaskName = vi.mocked(summarizeTaskName);
|
const mockSummarizeTaskName = vi.mocked(summarizeTaskName);
|
||||||
const mockSuccess = vi.mocked(success);
|
const mockSuccess = vi.mocked(success);
|
||||||
const mockInfo = vi.mocked(info);
|
const mockInfo = vi.mocked(info);
|
||||||
|
const mockConfirm = vi.mocked(confirm);
|
||||||
|
const mockPromptInput = vi.mocked(promptInput);
|
||||||
|
|
||||||
let testDir: string;
|
let testDir: string;
|
||||||
|
|
||||||
@ -163,16 +171,82 @@ describe('saveTaskFile', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('saveTaskFromInteractive', () => {
|
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
|
// When
|
||||||
await saveTaskFromInteractive(testDir, 'Task content');
|
await saveTaskFromInteractive(testDir, 'Task content');
|
||||||
|
|
||||||
// Then
|
// Then
|
||||||
expect(mockSuccess).toHaveBeenCalledWith('Task created: test-task.yaml');
|
expect(mockSuccess).toHaveBeenCalledWith('Task created: test-task.yaml');
|
||||||
expect(mockInfo).toHaveBeenCalledWith(expect.stringContaining('Path:'));
|
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 () => {
|
it('should display piece info when specified', async () => {
|
||||||
|
// Given
|
||||||
|
mockConfirm.mockResolvedValueOnce(false); // Create worktree? → No
|
||||||
|
|
||||||
// When
|
// When
|
||||||
await saveTaskFromInteractive(testDir, 'Task content', 'review');
|
await saveTaskFromInteractive(testDir, 'Task content', 'review');
|
||||||
|
|
||||||
@ -181,6 +255,9 @@ describe('saveTaskFromInteractive', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should include piece in saved YAML', async () => {
|
it('should include piece in saved YAML', async () => {
|
||||||
|
// Given
|
||||||
|
mockConfirm.mockResolvedValueOnce(false); // Create worktree? → No
|
||||||
|
|
||||||
// When
|
// When
|
||||||
await saveTaskFromInteractive(testDir, 'Task content', 'custom');
|
await saveTaskFromInteractive(testDir, 'Task content', 'custom');
|
||||||
|
|
||||||
@ -193,6 +270,9 @@ describe('saveTaskFromInteractive', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should not display piece info when not specified', async () => {
|
it('should not display piece info when not specified', async () => {
|
||||||
|
// Given
|
||||||
|
mockConfirm.mockResolvedValueOnce(false); // Create worktree? → No
|
||||||
|
|
||||||
// When
|
// When
|
||||||
await saveTaskFromInteractive(testDir, 'Task content');
|
await saveTaskFromInteractive(testDir, 'Task content');
|
||||||
|
|
||||||
@ -202,4 +282,18 @@ describe('saveTaskFromInteractive', () => {
|
|||||||
);
|
);
|
||||||
expect(pieceInfoCalls.length).toBe(0);
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -71,7 +71,7 @@ describe('preventSleep', () => {
|
|||||||
|
|
||||||
expect(spawn).toHaveBeenCalledWith(
|
expect(spawn).toHaveBeenCalledWith(
|
||||||
'/usr/bin/caffeinate',
|
'/usr/bin/caffeinate',
|
||||||
['-i', '-w', String(process.pid)],
|
['-di', '-w', String(process.pid)],
|
||||||
{ stdio: 'ignore', detached: true }
|
{ stdio: 'ignore', detached: true }
|
||||||
);
|
);
|
||||||
expect(mockChild.unref).toHaveBeenCalled();
|
expect(mockChild.unref).toHaveBeenCalled();
|
||||||
|
|||||||
@ -23,6 +23,15 @@ vi.mock('../shared/i18n/index.js', () => ({
|
|||||||
getLabel: vi.fn((key: string) => key),
|
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();
|
const mockExecuteAndCompleteTask = vi.fn();
|
||||||
|
|
||||||
vi.mock('../features/tasks/execute/taskExecution.js', () => ({
|
vi.mock('../features/tasks/execute/taskExecution.js', () => ({
|
||||||
@ -34,6 +43,8 @@ import { info } from '../shared/ui/index.js';
|
|||||||
|
|
||||||
const mockInfo = vi.mocked(info);
|
const mockInfo = vi.mocked(info);
|
||||||
|
|
||||||
|
const TEST_POLL_INTERVAL_MS = 50;
|
||||||
|
|
||||||
function createTask(name: string): TaskInfo {
|
function createTask(name: string): TaskInfo {
|
||||||
return {
|
return {
|
||||||
name,
|
name,
|
||||||
@ -68,7 +79,7 @@ describe('runWithWorkerPool', () => {
|
|||||||
const runner = createMockTaskRunner([]);
|
const runner = createMockTaskRunner([]);
|
||||||
|
|
||||||
// When
|
// 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
|
// Then
|
||||||
expect(result).toEqual({ success: 2, fail: 0 });
|
expect(result).toEqual({ success: 2, fail: 0 });
|
||||||
@ -85,7 +96,7 @@ describe('runWithWorkerPool', () => {
|
|||||||
const runner = createMockTaskRunner([]);
|
const runner = createMockTaskRunner([]);
|
||||||
|
|
||||||
// When
|
// 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
|
// Then
|
||||||
expect(result).toEqual({ success: 2, fail: 1 });
|
expect(result).toEqual({ success: 2, fail: 1 });
|
||||||
@ -97,7 +108,7 @@ describe('runWithWorkerPool', () => {
|
|||||||
const runner = createMockTaskRunner([]);
|
const runner = createMockTaskRunner([]);
|
||||||
|
|
||||||
// When
|
// 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
|
// Then
|
||||||
expect(mockInfo).toHaveBeenCalledWith('=== Task: alpha ===');
|
expect(mockInfo).toHaveBeenCalledWith('=== Task: alpha ===');
|
||||||
@ -110,7 +121,7 @@ describe('runWithWorkerPool', () => {
|
|||||||
const runner = createMockTaskRunner([]);
|
const runner = createMockTaskRunner([]);
|
||||||
|
|
||||||
// When
|
// 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
|
// Then
|
||||||
expect(mockExecuteAndCompleteTask).toHaveBeenCalledTimes(1);
|
expect(mockExecuteAndCompleteTask).toHaveBeenCalledTimes(1);
|
||||||
@ -127,7 +138,7 @@ describe('runWithWorkerPool', () => {
|
|||||||
const runner = createMockTaskRunner([]);
|
const runner = createMockTaskRunner([]);
|
||||||
|
|
||||||
// When
|
// 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
|
// Then
|
||||||
expect(mockExecuteAndCompleteTask).toHaveBeenCalledTimes(1);
|
expect(mockExecuteAndCompleteTask).toHaveBeenCalledTimes(1);
|
||||||
@ -145,7 +156,7 @@ describe('runWithWorkerPool', () => {
|
|||||||
const runner = createMockTaskRunner([[task2]]);
|
const runner = createMockTaskRunner([[task2]]);
|
||||||
|
|
||||||
// When
|
// 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
|
// Then
|
||||||
expect(mockExecuteAndCompleteTask).toHaveBeenCalledTimes(2);
|
expect(mockExecuteAndCompleteTask).toHaveBeenCalledTimes(2);
|
||||||
@ -173,7 +184,7 @@ describe('runWithWorkerPool', () => {
|
|||||||
const runner = createMockTaskRunner([]);
|
const runner = createMockTaskRunner([]);
|
||||||
|
|
||||||
// When
|
// 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
|
// Then: Never exceeded concurrency of 2
|
||||||
expect(maxActive).toBeLessThanOrEqual(2);
|
expect(maxActive).toBeLessThanOrEqual(2);
|
||||||
@ -192,7 +203,7 @@ describe('runWithWorkerPool', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// When
|
// 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
|
// Then: All tasks received the same AbortSignal
|
||||||
expect(receivedSignals).toHaveLength(3);
|
expect(receivedSignals).toHaveLength(3);
|
||||||
@ -208,7 +219,7 @@ describe('runWithWorkerPool', () => {
|
|||||||
const runner = createMockTaskRunner([]);
|
const runner = createMockTaskRunner([]);
|
||||||
|
|
||||||
// When
|
// 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
|
// Then
|
||||||
expect(result).toEqual({ success: 0, fail: 0 });
|
expect(result).toEqual({ success: 0, fail: 0 });
|
||||||
@ -222,9 +233,107 @@ describe('runWithWorkerPool', () => {
|
|||||||
const runner = createMockTaskRunner([]);
|
const runner = createMockTaskRunner([]);
|
||||||
|
|
||||||
// When
|
// 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
|
// Then: Treated as failure
|
||||||
expect(result).toEqual({ success: 0, fail: 1 });
|
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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -71,6 +71,8 @@ export interface GlobalConfig {
|
|||||||
interactivePreviewMovements?: number;
|
interactivePreviewMovements?: number;
|
||||||
/** Number of tasks to run concurrently in takt run (default: 1 = sequential) */
|
/** Number of tasks to run concurrently in takt run (default: 1 = sequential) */
|
||||||
concurrency: number;
|
concurrency: number;
|
||||||
|
/** Polling interval in ms for picking up new tasks during takt run (default: 500, range: 100-5000) */
|
||||||
|
taskPollIntervalMs: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Project-level configuration */
|
/** Project-level configuration */
|
||||||
|
|||||||
@ -322,6 +322,8 @@ export const GlobalConfigSchema = z.object({
|
|||||||
interactive_preview_movements: z.number().int().min(0).max(10).optional().default(3),
|
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) */
|
/** 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),
|
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 */
|
/** Project config schema */
|
||||||
|
|||||||
@ -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.
|
* 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(
|
export async function saveTaskFromInteractive(
|
||||||
cwd: string,
|
cwd: string,
|
||||||
task: string,
|
task: string,
|
||||||
piece?: string,
|
piece?: string,
|
||||||
): Promise<void> {
|
): 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);
|
const filename = path.basename(filePath);
|
||||||
success(`Task created: ${filename}`);
|
success(`Task created: ${filename}`);
|
||||||
info(` Path: ${filePath}`);
|
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}`);
|
if (piece) info(` Piece: ${piece}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -173,43 +206,25 @@ export async function addTask(cwd: string, task?: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3. ワークツリー/ブランチ/PR設定
|
// 3. ワークツリー/ブランチ/PR設定
|
||||||
let worktree: boolean | string | undefined;
|
const settings = await promptWorktreeSettings();
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// YAMLファイル作成
|
// YAMLファイル作成
|
||||||
const filePath = await saveTaskFile(cwd, taskContent, {
|
const filePath = await saveTaskFile(cwd, taskContent, {
|
||||||
piece,
|
piece,
|
||||||
issue: issueNumber,
|
issue: issueNumber,
|
||||||
worktree,
|
...settings,
|
||||||
branch,
|
|
||||||
autoPr,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const filename = path.basename(filePath);
|
const filename = path.basename(filePath);
|
||||||
success(`Task created: ${filename}`);
|
success(`Task created: ${filename}`);
|
||||||
info(` Path: ${filePath}`);
|
info(` Path: ${filePath}`);
|
||||||
if (worktree) {
|
if (settings.worktree) {
|
||||||
info(` Worktree: ${typeof worktree === 'string' ? worktree : 'auto'}`);
|
info(` Worktree: ${typeof settings.worktree === 'string' ? settings.worktree : 'auto'}`);
|
||||||
}
|
}
|
||||||
if (branch) {
|
if (settings.branch) {
|
||||||
info(` Branch: ${branch}`);
|
info(` Branch: ${settings.branch}`);
|
||||||
}
|
}
|
||||||
if (autoPr) {
|
if (settings.autoPr) {
|
||||||
info(` Auto-PR: yes`);
|
info(` Auto-PR: yes`);
|
||||||
}
|
}
|
||||||
if (piece) {
|
if (piece) {
|
||||||
|
|||||||
@ -5,19 +5,74 @@
|
|||||||
* available task as soon as it finishes the current one, maximizing slot
|
* available task as soon as it finishes the current one, maximizing slot
|
||||||
* utilization. Works for both sequential (concurrency=1) and parallel
|
* utilization. Works for both sequential (concurrency=1) and parallel
|
||||||
* (concurrency>1) execution through the same code path.
|
* (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 type { TaskRunner, TaskInfo } from '../../../infra/task/index.js';
|
||||||
import { info, blankLine } from '../../../shared/ui/index.js';
|
import { info, blankLine } from '../../../shared/ui/index.js';
|
||||||
|
import { createLogger } from '../../../shared/utils/index.js';
|
||||||
import { executeAndCompleteTask } from './taskExecution.js';
|
import { executeAndCompleteTask } from './taskExecution.js';
|
||||||
import { installSigIntHandler } from './sigintHandler.js';
|
import { installSigIntHandler } from './sigintHandler.js';
|
||||||
import type { TaskExecutionOptions } from './types.js';
|
import type { TaskExecutionOptions } from './types.js';
|
||||||
|
|
||||||
|
const log = createLogger('worker-pool');
|
||||||
|
|
||||||
export interface WorkerPoolResult {
|
export interface WorkerPoolResult {
|
||||||
success: number;
|
success: number;
|
||||||
fail: 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.
|
* Run tasks using a worker pool with the given concurrency.
|
||||||
*
|
*
|
||||||
@ -25,9 +80,10 @@ export interface WorkerPoolResult {
|
|||||||
* 1. Create a shared AbortController
|
* 1. Create a shared AbortController
|
||||||
* 2. Maintain a queue of pending tasks and a set of active promises
|
* 2. Maintain a queue of pending tasks and a set of active promises
|
||||||
* 3. Fill available slots from the queue
|
* 3. Fill available slots from the queue
|
||||||
* 4. Wait for any active task to complete (Promise.race)
|
* 4. Wait for any active task to complete OR a poll timer to fire (Promise.race)
|
||||||
* 5. Record result, fill freed slot from queue
|
* 5. On task completion: record result
|
||||||
* 6. Repeat until queue is empty and all active tasks complete
|
* 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(
|
export async function runWithWorkerPool(
|
||||||
taskRunner: TaskRunner,
|
taskRunner: TaskRunner,
|
||||||
@ -35,7 +91,8 @@ export async function runWithWorkerPool(
|
|||||||
concurrency: number,
|
concurrency: number,
|
||||||
cwd: string,
|
cwd: string,
|
||||||
pieceName: string,
|
pieceName: string,
|
||||||
options?: TaskExecutionOptions,
|
options: TaskExecutionOptions | undefined,
|
||||||
|
pollIntervalMs: number,
|
||||||
): Promise<WorkerPoolResult> {
|
): Promise<WorkerPoolResult> {
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
const { cleanup } = installSigIntHandler(() => abortController.abort());
|
const { cleanup } = installSigIntHandler(() => abortController.abort());
|
||||||
@ -58,13 +115,20 @@ export async function runWithWorkerPool(
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
const settled = await Promise.race(
|
const pollTimer = createPollTimer(pollIntervalMs, abortController.signal);
|
||||||
[...active.keys()].map((p) => p.then(
|
|
||||||
(result) => ({ promise: p, result }),
|
const completionPromises: Promise<RaceResult>[] = [...active.keys()].map((p) =>
|
||||||
() => ({ promise: p, result: false }),
|
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);
|
const task = active.get(settled.promise);
|
||||||
active.delete(settled.promise);
|
active.delete(settled.promise);
|
||||||
|
|
||||||
@ -75,10 +139,20 @@ export async function runWithWorkerPool(
|
|||||||
failCount++;
|
failCount++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!abortController.signal.aborted && queue.length === 0) {
|
if (!abortController.signal.aborted) {
|
||||||
const nextTasks = taskRunner.claimNextTasks(concurrency - active.size);
|
const freeSlots = concurrency - active.size;
|
||||||
queue.push(...nextTasks);
|
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 {
|
} finally {
|
||||||
|
|||||||
@ -220,7 +220,7 @@ export async function runAllTasks(
|
|||||||
info(`Concurrency: ${concurrency}`);
|
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;
|
const totalCount = result.success + result.fail;
|
||||||
blankLine();
|
blankLine();
|
||||||
|
|||||||
@ -37,6 +37,7 @@ function createDefaultGlobalConfig(): GlobalConfig {
|
|||||||
enableBuiltinPieces: true,
|
enableBuiltinPieces: true,
|
||||||
interactivePreviewMovements: 3,
|
interactivePreviewMovements: 3,
|
||||||
concurrency: 1,
|
concurrency: 1,
|
||||||
|
taskPollIntervalMs: 500,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -110,6 +111,7 @@ export class GlobalConfigManager {
|
|||||||
notificationSound: parsed.notification_sound,
|
notificationSound: parsed.notification_sound,
|
||||||
interactivePreviewMovements: parsed.interactive_preview_movements,
|
interactivePreviewMovements: parsed.interactive_preview_movements,
|
||||||
concurrency: parsed.concurrency,
|
concurrency: parsed.concurrency,
|
||||||
|
taskPollIntervalMs: parsed.task_poll_interval_ms,
|
||||||
};
|
};
|
||||||
validateProviderModelCompatibility(config.provider, config.model);
|
validateProviderModelCompatibility(config.provider, config.model);
|
||||||
this.cachedConfig = config;
|
this.cachedConfig = config;
|
||||||
@ -185,6 +187,9 @@ export class GlobalConfigManager {
|
|||||||
if (config.concurrency !== undefined && config.concurrency > 1) {
|
if (config.concurrency !== undefined && config.concurrency > 1) {
|
||||||
raw.concurrency = config.concurrency;
|
raw.concurrency = config.concurrency;
|
||||||
}
|
}
|
||||||
|
if (config.taskPollIntervalMs !== undefined && config.taskPollIntervalMs !== 500) {
|
||||||
|
raw.task_poll_interval_ms = config.taskPollIntervalMs;
|
||||||
|
}
|
||||||
writeFileSync(configPath, stringifyYaml(raw), 'utf-8');
|
writeFileSync(configPath, stringifyYaml(raw), 'utf-8');
|
||||||
this.invalidateCache();
|
this.invalidateCache();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,7 +8,9 @@ const log = createLogger('sleep');
|
|||||||
let caffeinateStarted = false;
|
let caffeinateStarted = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* takt実行中のmacOSアイドルスリープを防止する。
|
* takt実行中のmacOSアイドルスリープおよびディスプレイスリープを防止する。
|
||||||
|
* -d: ディスプレイスリープ防止(App Nap によるプロセス凍結を回避)
|
||||||
|
* -i: アイドルスリープ防止
|
||||||
* 蓋を閉じた場合のスリープは防げない(-s はAC電源が必要なため)。
|
* 蓋を閉じた場合のスリープは防げない(-s はAC電源が必要なため)。
|
||||||
*/
|
*/
|
||||||
export function preventSleep(): void {
|
export function preventSleep(): void {
|
||||||
@ -26,7 +28,7 @@ export function preventSleep(): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const child = spawn(caffeinatePath, ['-i', '-w', String(process.pid)], {
|
const child = spawn(caffeinatePath, ['-di', '-w', String(process.pid)], {
|
||||||
stdio: 'ignore',
|
stdio: 'ignore',
|
||||||
detached: true,
|
detached: true,
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user