commit
2f8ac2dd80
14
CHANGELOG.md
14
CHANGELOG.md
@ -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
|
||||
|
||||
@ -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で渡す。
|
||||
|
||||
@ -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
4
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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
16
vitest.config.e2e.base.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
};
|
||||
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user