worktreeにタスク指示書をコピー

This commit is contained in:
nrslib 2026-02-11 10:03:30 +09:00
parent c42799739e
commit 77cd485c22
5 changed files with 109 additions and 9 deletions

View File

@ -2,6 +2,9 @@
* Tests for resolveTaskExecution
*/
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock dependencies before importing the module under test
@ -522,7 +525,13 @@ describe('resolveTaskExecution', () => {
expect(mockCreateSharedClone).not.toHaveBeenCalled();
});
it('should return reportDirName from taskDir basename', async () => {
it('should stage task_dir spec into run context and return reportDirName', async () => {
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'takt-taskdir-normal-'));
const projectDir = path.join(tmpRoot, 'project');
fs.mkdirSync(path.join(projectDir, '.takt', 'tasks', '20260201-015714-foptng'), { recursive: true });
const sourceOrder = path.join(projectDir, '.takt', 'tasks', '20260201-015714-foptng', 'order.md');
fs.writeFileSync(sourceOrder, '# normal task spec\n', 'utf-8');
const task: TaskInfo = {
name: 'task-with-dir',
content: 'Task content',
@ -533,9 +542,15 @@ describe('resolveTaskExecution', () => {
},
};
const result = await resolveTaskExecution(task, '/project', 'default');
const result = await resolveTaskExecution(task, projectDir, 'default');
expect(result.reportDirName).toBe('20260201-015714-foptng');
expect(result.execCwd).toBe(projectDir);
const stagedOrder = path.join(projectDir, '.takt', 'runs', '20260201-015714-foptng', 'context', 'task', 'order.md');
expect(fs.existsSync(stagedOrder)).toBe(true);
expect(fs.readFileSync(stagedOrder, 'utf-8')).toContain('normal task spec');
expect(result.taskPrompt).toContain('Primary spec: `.takt/runs/20260201-015714-foptng/context/task/order.md`.');
expect(result.taskPrompt).not.toContain(projectDir);
});
it('should throw when taskDir format is invalid', async () => {
@ -569,4 +584,41 @@ describe('resolveTaskExecution', () => {
'Invalid task_dir format: .takt/tasks/..',
);
});
it('should stage task_dir spec into worktree run context and return run-scoped task prompt', async () => {
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'takt-taskdir-'));
const projectDir = path.join(tmpRoot, 'project');
const cloneDir = path.join(tmpRoot, 'clone');
fs.mkdirSync(path.join(projectDir, '.takt', 'tasks', '20260201-015714-foptng'), { recursive: true });
fs.mkdirSync(cloneDir, { recursive: true });
const sourceOrder = path.join(projectDir, '.takt', 'tasks', '20260201-015714-foptng', 'order.md');
fs.writeFileSync(sourceOrder, '# webhook task\n', 'utf-8');
const task: TaskInfo = {
name: 'task-with-taskdir-worktree',
content: 'Task content',
taskDir: '.takt/tasks/20260201-015714-foptng',
filePath: '/tasks/task.yaml',
data: {
task: 'Task content',
worktree: true,
},
};
mockSummarizeTaskName.mockResolvedValue('webhook-task');
mockCreateSharedClone.mockReturnValue({
path: cloneDir,
branch: 'takt/webhook-task',
});
const result = await resolveTaskExecution(task, projectDir, 'default');
const stagedOrder = path.join(cloneDir, '.takt', 'runs', '20260201-015714-foptng', 'context', 'task', 'order.md');
expect(fs.existsSync(stagedOrder)).toBe(true);
expect(fs.readFileSync(stagedOrder, 'utf-8')).toContain('webhook task');
expect(result.taskPrompt).toContain('Implement using only the files in `.takt/runs/20260201-015714-foptng/context/task`.');
expect(result.taskPrompt).toContain('Primary spec: `.takt/runs/20260201-015714-foptng/context/task/order.md`.');
expect(result.taskPrompt).not.toContain(projectDir);
});
});

View File

@ -36,8 +36,6 @@ import { executeDefaultAction } from './routing.js';
// Normal parsing for all other cases (including '#' prefixed inputs)
await program.parseAsync();
// Some providers/SDKs may leave active handles even after command completion.
// Keep only watch mode as a long-running command; all others should exit explicitly.
const rootArg = process.argv.slice(2)[0];
if (rootArg !== 'watch') {
process.exit(0);

View File

@ -2,6 +2,8 @@
* Resolve execution directory and piece from task data.
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import { loadGlobalConfig } from '../../../infra/config/index.js';
import { type TaskInfo, createSharedClone, summarizeTaskName, getCurrentBranch } from '../../../infra/task/index.js';
import { info, withProgress } from '../../../shared/ui/index.js';
@ -11,6 +13,7 @@ export interface ResolvedTaskExecution {
execCwd: string;
execPiece: string;
isWorktree: boolean;
taskPrompt?: string;
reportDirName?: string;
branch?: string;
baseBranch?: string;
@ -20,6 +23,36 @@ export interface ResolvedTaskExecution {
issueNumber?: number;
}
function buildRunTaskDirInstruction(reportDirName: string): string {
const runTaskDir = `.takt/runs/${reportDirName}/context/task`;
const orderFile = `${runTaskDir}/order.md`;
return [
`Implement using only the files in \`${runTaskDir}\`.`,
`Primary spec: \`${orderFile}\`.`,
'Use report files in Report Directory as primary execution history.',
'Do not rely on previous response or conversation summary.',
].join('\n');
}
function stageTaskSpecForExecution(
projectCwd: string,
execCwd: string,
taskDir: string,
reportDirName: string,
): string {
const sourceOrderPath = path.join(projectCwd, taskDir, 'order.md');
if (!fs.existsSync(sourceOrderPath)) {
throw new Error(`Task spec file is missing: ${sourceOrderPath}`);
}
const targetTaskDir = path.join(execCwd, '.takt', 'runs', reportDirName, 'context', 'task');
const targetOrderPath = path.join(targetTaskDir, 'order.md');
fs.mkdirSync(targetTaskDir, { recursive: true });
fs.copyFileSync(sourceOrderPath, targetOrderPath);
return buildRunTaskDirInstruction(reportDirName);
}
function throwIfAborted(signal?: AbortSignal): void {
if (signal?.aborted) {
throw new Error('Task execution aborted');
@ -47,6 +80,7 @@ export async function resolveTaskExecution(
let execCwd = defaultCwd;
let isWorktree = false;
let reportDirName: string | undefined;
let taskPrompt: string | undefined;
let branch: string | undefined;
let baseBranch: string | undefined;
if (task.taskDir) {
@ -81,6 +115,11 @@ export async function resolveTaskExecution(
execCwd = result.path;
branch = result.branch;
isWorktree = true;
}
if (task.taskDir && reportDirName) {
taskPrompt = stageTaskSpecForExecution(defaultCwd, execCwd, task.taskDir, reportDirName);
}
const execPiece = data.piece || defaultPiece;
@ -99,6 +138,7 @@ export async function resolveTaskExecution(
execCwd,
execPiece,
isWorktree,
...(taskPrompt ? { taskPrompt } : {}),
...(reportDirName ? { reportDirName } : {}),
...(branch ? { branch } : {}),
...(baseBranch ? { baseBranch } : {}),

View File

@ -130,11 +130,23 @@ export async function executeAndCompleteTask(
}
try {
const { execCwd, execPiece, isWorktree, reportDirName, branch, baseBranch, startMovement, retryNote, autoPr, issueNumber } = await resolveTaskExecution(task, cwd, pieceName, taskAbortSignal);
const {
execCwd,
execPiece,
isWorktree,
taskPrompt,
reportDirName,
branch,
baseBranch,
startMovement,
retryNote,
autoPr,
issueNumber,
} = await resolveTaskExecution(task, cwd, pieceName, taskAbortSignal);
// cwd is always the project root; pass it as projectCwd so reports/sessions go there
const taskRunResult = await executeTaskWithResult({
task: task.content,
task: taskPrompt ?? task.content,
cwd: execCwd,
pieceIdentifier: execPiece,
projectCwd: cwd,

View File

@ -14,9 +14,7 @@ function pauseStdinSafely(): void {
if (process.stdin.readable && !process.stdin.destroyed) {
process.stdin.pause();
}
} catch {
// Ignore stdin state errors during prompt cleanup.
}
} catch {}
}
/**