Merge pull request #282 from nrslib/release/v0.17.3

Release v0.17.3
This commit is contained in:
nrs 2026-02-16 17:09:50 +09:00 committed by GitHub
commit 2f8ac2dd80
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 79 additions and 177 deletions

View File

@ -4,6 +4,20 @@ 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.17.3] - 2026-02-16
### Added
- ビルトインの AI アンチパターンポリシーとフロントエンドナレッジに API クライアント生成の一貫性ルールを追加 — 生成ツールOrval 等)が存在するプロジェクトでの手書きクライアント混在を検出
### Fixed
- タスクストアのロック解放時に EPERM クラッシュが発生する問題を修正 — ファイルベースロックからインメモリガードに置き換え
### Internal
- e2e テストの vitest 設定を共通化し、forceExit オプション追加でゾンビワーカーを防止
## [0.17.2] - 2026-02-15
### Added

View File

@ -156,6 +156,21 @@ const Parent = () => {
| 複数コンポーネントで共有 | Context or 状態管理ライブラリ |
| サーバーデータのキャッシュ | TanStack Query等のデータフェッチライブラリ |
## APIクライアント生成
プロジェクトがAPIクライアント生成ツールOrval、openapi-typescript等を採用している場合、新規APIエンドポイントとの接続には必ず生成されたクライアントを使用する。
| パターン | 判定 |
|---------|------|
| 生成ツールが存在するのに axiosInstance/fetch を直接使用 | REJECT |
| 生成ツールの設定を確認せずにAPIフックを手書き | REJECT |
| 生成ツールが存在しないプロジェクトで直接呼び出し | OK |
確認手順:
1. プロジェクトにAPI生成設定があるか確認orval.config.ts, openapi-generator 等)
2. 既存の生成済みクライアントの使用パターンを確認
3. 新規エンドポイントは生成パイプラインに追加し、生成されたフックを使う
## データ取得
API呼び出しはルートViewコンポーネントで行い、子コンポーネントにはpropsで渡す。

View File

@ -63,6 +63,22 @@ AIは同じパターンを、間違いも含めて繰り返すことが多い。
- ここに属しているように感じるか?
- プロジェクト規則からの説明のない逸脱はないか?
## インテグレーションパターンの一貫性
同じ種類のAPI接続REST呼び出し等がプロジェクト内で異なる方式で実装されていないか確認する。
| パターン | 例 | 判定 |
|---------|-----|------|
| 生成クライアントと手書きクライアントの混在 | A画面はOrval生成フック、B画面はaxiosInstance直接 | REJECT |
| 同じデータ取得パターンの異なる実装 | A画面はuseQuery+axios、B画面は生成フック | REJECT |
| データ型の定義方式の混在 | A画面は生成された型、B画面は手書きの型 | REJECT |
検証アプローチ:
1. 変更差分のAPI呼び出し方式を確認
2. 同じ目的の既存コードがどの方式で書かれているか grep で確認
3. プロジェクトにAPI生成設定orval.config.ts等があるか確認
4. 不整合がある場合、プロジェクトの標準パターンへの統一を指摘する
## スコープクリープ検出
AIは過剰に提供する傾向がある。不要な追加をチェック。

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "takt",
"version": "0.17.2",
"version": "0.17.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "takt",
"version": "0.17.2",
"version": "0.17.3",
"license": "MIT",
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.37",

View File

@ -1,6 +1,6 @@
{
"name": "takt",
"version": "0.17.2",
"version": "0.17.3",
"description": "TAKT: TAKT Agent Koordination Topology - AI Agent Piece Orchestration",
"main": "dist/index.js",
"types": "dist/index.d.ts",

View File

@ -1,4 +1,4 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdirSync, writeFileSync, existsSync, rmSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
@ -94,32 +94,6 @@ describe('TaskRunner (tasks.yaml)', () => {
expect(recovered).toBe(0);
});
it('should take over stale lock file with invalid pid', () => {
mkdirSync(join(testDir, '.takt'), { recursive: true });
writeFileSync(join(testDir, '.takt', 'tasks.yaml.lock'), 'invalid-pid', 'utf-8');
const task = runner.addTask('Task with stale lock');
expect(task.name).toContain('task-with-stale-lock');
expect(existsSync(join(testDir, '.takt', 'tasks.yaml.lock'))).toBe(false);
});
it('should timeout when lock file is held by a live process', () => {
mkdirSync(join(testDir, '.takt'), { recursive: true });
writeFileSync(join(testDir, '.takt', 'tasks.yaml.lock'), String(process.pid), 'utf-8');
const dateNowSpy = vi.spyOn(Date, 'now');
dateNowSpy.mockReturnValueOnce(0);
dateNowSpy.mockReturnValue(5_000);
try {
expect(() => runner.listTasks()).toThrow('Failed to acquire tasks lock within 5000ms');
} finally {
dateNowSpy.mockRestore();
rmSync(join(testDir, '.takt', 'tasks.yaml.lock'), { force: true });
}
});
it('should recover from corrupted tasks.yaml and allow adding tasks again', () => {
mkdirSync(join(testDir, '.takt'), { recursive: true });
writeFileSync(join(testDir, '.takt', 'tasks.yaml'), 'tasks:\n - name: [broken', 'utf-8');

View File

@ -5,23 +5,15 @@ import { TasksFileSchema, type TasksFileData } from './schema.js';
import { createLogger } from '../../shared/utils/index.js';
const log = createLogger('task-store');
const LOCK_WAIT_MS = 5_000;
const LOCK_POLL_MS = 50;
function sleepSync(ms: number): void {
const arr = new Int32Array(new SharedArrayBuffer(4));
Atomics.wait(arr, 0, 0, ms);
}
export class TaskStore {
private readonly tasksFile: string;
private readonly lockFile: string;
private readonly taktDir: string;
private locked = false;
constructor(private readonly projectDir: string) {
this.taktDir = path.join(projectDir, '.takt');
this.tasksFile = path.join(this.taktDir, 'tasks.yaml');
this.lockFile = path.join(this.taktDir, 'tasks.yaml.lock');
}
getTasksFilePath(): string {
@ -79,103 +71,14 @@ export class TaskStore {
}
private withLock<T>(fn: () => T): T {
this.acquireLock();
if (this.locked) {
throw new Error('TaskStore: reentrant lock detected');
}
this.locked = true;
try {
return fn();
} finally {
this.releaseLock();
}
}
private acquireLock(): void {
this.ensureDirs();
const start = Date.now();
while (true) {
try {
fs.writeFileSync(this.lockFile, String(process.pid), { encoding: 'utf-8', flag: 'wx' });
return;
} catch (err) {
const nodeErr = err as NodeJS.ErrnoException;
if (nodeErr.code !== 'EEXIST') {
throw err;
}
}
if (this.isStaleLock()) {
this.removeStaleLock();
continue;
}
if (Date.now() - start >= LOCK_WAIT_MS) {
throw new Error(`Failed to acquire tasks lock within ${LOCK_WAIT_MS}ms`);
}
sleepSync(LOCK_POLL_MS);
}
}
private isStaleLock(): boolean {
let pidRaw: string;
try {
pidRaw = fs.readFileSync(this.lockFile, 'utf-8').trim();
} catch (err) {
const nodeErr = err as NodeJS.ErrnoException;
if (nodeErr.code === 'ENOENT') {
return false;
}
throw err;
}
const pid = Number.parseInt(pidRaw, 10);
if (!Number.isInteger(pid) || pid <= 0) {
return true;
}
return !this.isProcessAlive(pid);
}
private removeStaleLock(): void {
try {
fs.unlinkSync(this.lockFile);
} catch (err) {
const nodeErr = err as NodeJS.ErrnoException;
if (nodeErr.code !== 'ENOENT') {
log.debug('Failed to remove stale lock, retrying.', { lockFile: this.lockFile, error: String(err) });
}
}
}
private isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (err) {
const nodeErr = err as NodeJS.ErrnoException;
if (nodeErr.code === 'ESRCH') {
return false;
}
if (nodeErr.code === 'EPERM') {
return true;
}
throw err;
}
}
private releaseLock(): void {
try {
const holder = fs.readFileSync(this.lockFile, 'utf-8').trim();
if (holder !== String(process.pid)) {
return;
}
fs.unlinkSync(this.lockFile);
} catch (err) {
const nodeErr = err as NodeJS.ErrnoException;
if (nodeErr.code === 'ENOENT') {
return;
}
log.debug('Failed to release tasks lock.', { lockFile: this.lockFile, error: String(err) });
throw err;
this.locked = false;
}
}
}

16
vitest.config.e2e.base.ts Normal file
View File

@ -0,0 +1,16 @@
import { UserConfig } from 'vitest/config';
export const e2eBaseTestConfig: UserConfig['test'] = {
environment: 'node',
globals: false,
testTimeout: 240000,
hookTimeout: 60000,
teardownTimeout: 30000,
forceExit: true,
pool: 'threads',
poolOptions: {
threads: {
singleThread: true,
},
},
};

View File

@ -1,7 +1,9 @@
import { defineConfig } from 'vitest/config';
import { e2eBaseTestConfig } from './vitest.config.e2e.base';
export default defineConfig({
test: {
...e2eBaseTestConfig,
include: [
'e2e/specs/direct-task.e2e.ts',
'e2e/specs/pipeline-skip-git.e2e.ts',
@ -34,16 +36,5 @@ export default defineConfig({
'e2e/specs/quiet-mode.e2e.ts',
'e2e/specs/task-content-file.e2e.ts',
],
environment: 'node',
globals: false,
testTimeout: 240000,
hookTimeout: 60000,
teardownTimeout: 30000,
pool: 'threads',
poolOptions: {
threads: {
singleThread: true,
},
},
},
});

View File

@ -1,7 +1,9 @@
import { defineConfig } from 'vitest/config';
import { e2eBaseTestConfig } from './vitest.config.e2e.base';
export default defineConfig({
test: {
...e2eBaseTestConfig,
include: [
'e2e/specs/add-and-run.e2e.ts',
'e2e/specs/worktree.e2e.ts',
@ -9,16 +11,5 @@ export default defineConfig({
'e2e/specs/github-issue.e2e.ts',
'e2e/specs/structured-output.e2e.ts',
],
environment: 'node',
globals: false,
testTimeout: 240000,
hookTimeout: 60000,
teardownTimeout: 30000,
pool: 'threads',
poolOptions: {
threads: {
singleThread: true,
},
},
},
});

View File

@ -1,20 +1,11 @@
import { defineConfig } from 'vitest/config';
import { e2eBaseTestConfig } from './vitest.config.e2e.base';
export default defineConfig({
test: {
...e2eBaseTestConfig,
include: [
'e2e/specs/structured-output.e2e.ts',
],
environment: 'node',
globals: false,
testTimeout: 240000,
hookTimeout: 60000,
teardownTimeout: 30000,
pool: 'threads',
poolOptions: {
threads: {
singleThread: true,
},
},
},
});

View File

@ -1,18 +1,9 @@
import { defineConfig } from 'vitest/config';
import { e2eBaseTestConfig } from './vitest.config.e2e.base';
export default defineConfig({
test: {
...e2eBaseTestConfig,
include: ['e2e/specs/**/*.e2e.ts'],
environment: 'node',
globals: false,
testTimeout: 240000,
hookTimeout: 60000,
teardownTimeout: 30000,
pool: 'threads',
poolOptions: {
threads: {
singleThread: true,
},
},
},
});