# pi-crew Refactor & Optimization Backlog > Tài liệu này liệt kê chi tiết các task tối ưu/cải thiện cho `pi-crew/`, sắp xếp theo thứ tự ưu tiên thực hiện. > Task #1 (tách `register.ts` & `team-tool.ts`) đã hoàn thành — xem CHANGELOG hoặc `src/extension/team-tool/`, `src/extension/registration/`. Mỗi task gồm: - **Vấn đề (Problem)** — bug/inefficiency hiện tại - **Vị trí (Location)** — file:line - **Đề xuất (Proposed fix)** — cách sửa - **Verification** — lệnh test xác nhận - **Rủi ro (Risk)** — tác động/rollback --- ## Trạng thái hoàn thành - [x] Task #1 — Tách `register.ts` & `team-tool.ts` (đã hoàn thành) - [x] Task #2 — Sửa `withRunLock` / `withRunLockSync` race condition + async blocking - [x] Task #3 — Tối ưu `nextSequence` trong `event-log.ts` (O(n²) → O(1)) - [x] Task #4 — Cache `loadRunManifestById` resolution - [x] Task #5 — Memoize task-graph maps trong `team-runner` loop - [x] Task #6 — Cleanup timers trong `child-pi.ts` - [x] Task #7 — `useProjectState` walk-up tìm git root - [x] Task #8 — Gom hard-coded constants vào `config/defaults.ts` - [x] Task #9 — Validate config bằng TypeBox - [x] Task #10 — Tách `ensureMailbox` khỏi read path - [x] Task #11 — `injectAdaptivePlanIfReady` chạy ít hơn - [x] Task #12 — Bỏ `jiti` khỏi runtime dependencies - [x] Task #13 — `atomicWriteFile` non-blocking variant - [x] Task #14 — `defaultWorkflowConcurrency` đọc từ workflow frontmatter - [x] Task #15 — Logging cho silent catches - [x] Task #16 — Cosmetic & cleanup ## #2 — Sửa `withRunLock` / `withRunLockSync` race condition + async blocking **Priority:** High — ảnh hưởng tính đúng đắn multi-process. ### Vấn đề - File: `src/state/locks.ts` - `withRunLockSync`: 1. Check `existsSync(filePath)` → nếu stale thì `rmSync` rồi `writeFileSync(flag: "wx")`. Hai process cùng thấy stale có thể chạy `rmSync` đồng thời, một process `wx` thành công, process kia ném lỗi `EEXIST` ngay → caller phải retry thủ công nhưng không có cơ chế retry. 2. Lock chỉ tồn tại trong scope `fn()` — nếu `fn()` throw, lock được release qua `finally` (đúng), nhưng khoảng thời gian giữa check stale và create file là race window. - `withRunLock` (async) chỉ wrap `withRunLockSync`: ```ts export async function withRunLock(manifest, fn, options) { return withRunLockSync(manifest, () => fn(), options); } ``` ⇒ Lock giữ trong khi `fn()` async chạy, nhưng `withRunLockSync` trả về **Promise object ngay sau khi gọi `fn()`** chứ không đợi Promise resolve → lock release **trước khi** async work hoàn tất. ### Đề xuất **Phương án A (nhỏ, bug-fix only):** ```ts export async function withRunLock(manifest: TeamRunManifest, fn: () => Promise, options: RunLockOptions = {}): Promise { const filePath = lockPath(manifest); const staleMs = options.staleMs ?? DEFAULT_STALE_MS; fs.mkdirSync(path.dirname(filePath), { recursive: true }); await acquireLockWithRetry(filePath, staleMs); try { return await fn(); } finally { try { fs.rmSync(filePath, { force: true }); } catch {} } } async function acquireLockWithRetry(filePath: string, staleMs: number): Promise { const deadline = Date.now() + staleMs * 2; let attempt = 0; while (true) { try { // O_CREAT | O_EXCL | O_WRONLY (atomic) const fd = fs.openSync(filePath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, 0o644); fs.writeSync(fd, JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() })); fs.closeSync(fd); return; } catch (error) { const code = (error as NodeJS.ErrnoException).code; if (code !== "EEXIST") throw error; // Check stale try { const stat = fs.statSync(filePath); if (Date.now() - stat.mtimeMs > staleMs) { fs.rmSync(filePath, { force: true }); continue; } } catch {} if (Date.now() > deadline) throw new Error(`Run lock '${filePath}' busy.`); await new Promise((resolve) => setTimeout(resolve, Math.min(250, 25 * 2 ** attempt))); attempt++; } } } ``` **Phương án B (dùng thư viện):** Cài `proper-lockfile` (~13KB, MIT). API: `lockfile.lock(filePath, { stale, retries })`. Production-grade, nhưng thêm dependency. ### Verification ```powershell npx tsc --noEmit node --experimental-strip-types --test test/unit/api-locks.test.ts node --experimental-strip-types --test test/unit/resume-cancel.test.ts test/unit/mailbox-api.test.ts ``` Test mới cần thêm: 2 process đồng thời gọi `withRunLock` cùng manifest → đúng 1 thành công tại một thời điểm. ### Rủi ro - API giữ nguyên (`withRunLock(manifest, fn, options)`) → backward compat. - Trên Windows, `O_EXCL` đôi khi flaky với antivirus — vẫn cần retry với backoff. --- ## #3 — Tối ưu `nextSequence` trong `event-log.ts` (O(n²) → O(1)) **Priority:** High — performance trên run dài (10k+ events). ### Vấn đề - File: `src/state/event-log.ts:46-65` - Cache hit nhanh, nhưng cache miss = đọc toàn bộ file + `JSON.parse` mỗi line: ```ts for (const line of fs.readFileSync(eventsPath, "utf-8").split("\n")) { const event = JSON.parse(line); max = Math.max(max, event.metadata?.seq ?? 0); } ``` - Mỗi process khác (background async runner, child Pi) ghi event → invalidate cache của process khác → mỗi append ở leader có thể trở thành full scan. - Kết quả: với 10k events, mỗi append ~5-50ms; tổng cộng O(n²). ### Đề xuất Lưu seq counter vào file riêng `events.seq`: ```ts // src/state/event-log.ts function seqFilePath(eventsPath: string): string { return `${eventsPath}.seq`; } function nextSequence(eventsPath: string): number { const seqPath = seqFilePath(eventsPath); let current = 0; try { current = Number.parseInt(fs.readFileSync(seqPath, "utf-8").trim(), 10); if (!Number.isFinite(current) || current < 0) current = 0; } catch { // First write or corrupted: scan once to recover if (fs.existsSync(eventsPath)) { for (const line of fs.readFileSync(eventsPath, "utf-8").split("\n")) { if (!line.trim()) continue; try { current = Math.max(current, (JSON.parse(line) as TeamEvent).metadata?.seq ?? 0); } catch { current++; } } } } const next = current + 1; try { atomicWriteFile(seqPath, String(next)); } catch { // Best effort; sequence will recover on next read } return next; } ``` Hoặc tốt hơn: dùng **incremental tail-read** từ cached size offset: ```ts const sequenceCache = new Map(); function nextSequence(eventsPath: string): number { if (!fs.existsSync(eventsPath)) return 1; const stat = fs.statSync(eventsPath); const cached = sequenceCache.get(eventsPath); if (cached && cached.mtimeMs === stat.mtimeMs && cached.size === stat.size) return cached.seq + 1; let max = cached?.seq ?? 0; let startOffset = cached && cached.size < stat.size ? cached.offset : 0; if (cached && cached.size > stat.size) { max = 0; startOffset = 0; } // file rotated const fd = fs.openSync(eventsPath, "r"); try { const buf = Buffer.alloc(stat.size - startOffset); fs.readSync(fd, buf, 0, buf.length, startOffset); for (const line of buf.toString("utf-8").split("\n")) { if (!line.trim()) continue; try { max = Math.max(max, (JSON.parse(line) as TeamEvent).metadata?.seq ?? 0); } catch { max++; } } } finally { fs.closeSync(fd); } sequenceCache.set(eventsPath, { size: stat.size, mtimeMs: stat.mtimeMs, seq: max, offset: stat.size }); return max + 1; } ``` ### Verification ```powershell node --experimental-strip-types --test test/unit/event-metadata.test.ts test/unit/run-events-artifacts.test.ts test/unit/phase5-observability.test.ts ``` Benchmark mới (optional): append 10k events, đo tổng thời gian — kỳ vọng < 1s thay vì 10-30s. ### Rủi ro - Phương án "seq file" đơn giản hơn, dễ verify; phải chú ý cleanup (`forget`/`prune` xóa luôn `.seq`). - Phương án incremental đọc đúng nhưng phức tạp hơn, cần test đặc biệt cho file rotation/truncate. --- ## #4 — Cache `loadRunManifestById` resolution **Priority:** Medium — UI overhead (powerbar/sidebar 1Hz). ### Vấn đề - File: `src/state/state-store.ts:104-115` - Mỗi lần gọi: 2 lần `fs.existsSync` + 2 lần `path.join` + `readFileSync`+`JSON.parse` cho cả manifest và tasks. - Được gọi từ: - `live-run-sidebar.ts` (1Hz timer) - `powerbar-publisher.ts` (1Hz) - `crew-widget.ts` (1Hz) - `subagent-helpers.refreshPersistedSubagentRecord` - `team-tool.handleStatus/Cancel/Resume/Events/Artifacts/Summary/Worktrees/Forget/Cleanup/Export/Api` ### Đề xuất Thêm tầng cache stat-based (giống `nextSequence`): ```ts // src/state/state-store.ts interface ManifestCacheEntry { manifest: TeamRunManifest; tasks: TeamTaskState[]; manifestMtime: number; tasksMtime: number; } const manifestCache = new Map(); function resolvedStateRoot(cwd: string, runId: string): string | undefined { const projectPath = path.join(projectPiRoot(cwd), "teams", "state", "runs", runId); if (fs.existsSync(projectPath)) return projectPath; const userPath = path.join(userPiRoot(), "extensions", "pi-crew", "runs", "state", "runs", runId); return fs.existsSync(userPath) ? userPath : undefined; } export function loadRunManifestById(cwd: string, runId: string): { manifest: TeamRunManifest; tasks: TeamTaskState[] } | undefined { const stateRoot = resolvedStateRoot(cwd, runId); if (!stateRoot) return undefined; const manifestPath = path.join(stateRoot, "manifest.json"); const tasksPath = path.join(stateRoot, "tasks.json"); let mStat: fs.Stats | undefined; let tStat: fs.Stats | undefined; try { mStat = fs.statSync(manifestPath); } catch { return undefined; } try { tStat = fs.statSync(tasksPath); } catch {} const cacheKey = `${stateRoot}`; const cached = manifestCache.get(cacheKey); if (cached && cached.manifestMtime === mStat.mtimeMs && cached.tasksMtime === (tStat?.mtimeMs ?? 0)) { return { manifest: cached.manifest, tasks: cached.tasks }; } const manifest = readJsonFile(manifestPath); if (!manifest) return undefined; const tasks = readJsonFile(tasksPath) ?? []; manifestCache.set(cacheKey, { manifest, tasks, manifestMtime: mStat.mtimeMs, tasksMtime: tStat?.mtimeMs ?? 0 }); return { manifest, tasks }; } ``` Quan trọng: `saveRunManifest` / `saveRunTasks` phải invalidate cache: ```ts export function saveRunManifest(manifest: TeamRunManifest): void { atomicWriteJson(path.join(manifest.stateRoot, "manifest.json"), manifest); manifestCache.delete(manifest.stateRoot); // OR: refresh entry } ``` ### Verification ```powershell node --experimental-strip-types --test test/unit/state-store.test.ts test/unit/team-run.test.ts test/unit/resume-cancel.test.ts test/unit/run-dashboard.test.ts test/unit/live-run-sidebar.test.ts ``` ### Rủi ro - Cross-process: process A cache → process B ghi → process A vẫn dùng cache cũ trong tầng mtime check. mtime resolution thường ≥1ms nên acceptable. - **Memory leak**: cache không bound. Thêm LRU max 50 entries. --- ## #5 — Memoize task-graph maps trong `team-runner` loop **Priority:** Medium — CPU/GC overhead trên run lớn (>100 tasks). ### Vấn đề - File: `src/runtime/task-graph-scheduler.ts` - Mỗi function (`getReadyTasks`, `markTaskRunning`, `markTaskDone`, `cancelTaskSubtree`, `failTaskAndBlockChildren`, `taskGraphSnapshot`) đều build lại 3 maps: - `completedStepIds(tasks)` — Set - `taskById(tasks)` — Map - `stepIdToTaskId(tasks)` — Map - Trong `executeTeamRun` loop, `refreshTaskGraphQueues` được gọi nhiều lần per iteration: - `team-runner.ts:240` (getReadyTasks) - `team-runner.ts:228` (taskGraphSnapshot) - Mỗi snapshot/refresh = 3 maps × O(n) ### Đề xuất Build maps 1 lần ở caller, truyền xuống: ```ts // task-graph-scheduler.ts export interface TaskGraphIndex { doneSteps: Set; byId: Map; byStepId: Map; } export function buildTaskGraphIndex(tasks: TeamTaskState[]): TaskGraphIndex { return { doneSteps: completedStepIds(tasks), byId: taskById(tasks), byStepId: stepIdToTaskId(tasks), }; } export function refreshTaskGraphQueues(tasks: TeamTaskState[], index?: TaskGraphIndex): TeamTaskState[] { const idx = index ?? buildTaskGraphIndex(tasks); return tasks.map((task) => { // ... use idx.doneSteps, idx.byId, idx.byStepId }); } ``` Trong `team-runner.executeTeamRun`: ```ts while (tasks.some((task) => task.status === "queued")) { const idx = buildTaskGraphIndex(tasks); const snapshot = taskGraphSnapshot(tasks, idx); const readyBatch = getReadyTasks(tasks, concurrency.selectedCount, idx); // ... tasks = mergeTaskUpdates(tasks, results); // (rebuild index after mutations) } ``` Hoặc: memoize bằng WeakMap với task array reference làm key: ```ts const indexCache = new WeakMap(); function ensureIndex(tasks: TeamTaskState[]): TaskGraphIndex { let idx = indexCache.get(tasks); if (!idx) { idx = buildTaskGraphIndex(tasks); indexCache.set(tasks, idx); } return idx; } ``` (Pattern này hoạt động vì `tasks.map()` luôn trả mảng mới → cache key đổi tự động khi mutation.) ### Verification ```powershell node --experimental-strip-types --test test/unit/task-graph-scheduler.test.ts test/unit/phase3-runtime.test.ts test/unit/phase4-runtime.test.ts test/unit/implementation-fanout.test.ts ``` ### Rủi ro - Refactor lan rộng (5-6 callsite) nhưng giữ API cũ với optional param `index?` → backward compat. --- ## #6 — Cleanup timers trong `child-pi.ts` **Priority:** Medium — leak nhẹ nhưng dễ tích lũy. ### Vấn đề - File: `src/runtime/child-pi.ts:31-39` - `killProcessTree` schedule SIGKILL sau `HARD_KILL_MS`: ```ts setTimeout(() => { try { process.kill(-pid, "SIGKILL"); } catch { ... } }, HARD_KILL_MS).unref?.(); ``` - Không clear khi child exit bình thường giữa SIGTERM-SIGKILL window. Trên hệ thống chạy nhiều run, hàng trăm timer pending mỗi giờ. ### Đề xuất Track timer và clear trong `child.on('exit')`: ```ts function killProcessTree(pid: number | undefined, child?: ChildProcess): void { if (!pid || !Number.isInteger(pid) || pid <= 0) return; try { if (process.platform === "win32") { spawn("taskkill", ["/pid", String(pid), "/t", "/f"], { stdio: "ignore", windowsHide: true }); return; } try { process.kill(-pid, "SIGTERM"); } catch { process.kill(pid, "SIGTERM"); } const killTimer = setTimeout(() => { try { process.kill(-pid, "SIGKILL"); } catch { try { process.kill(pid, "SIGKILL"); } catch {} } }, HARD_KILL_MS); killTimer.unref?.(); child?.once("exit", () => clearTimeout(killTimer)); } catch { // Ignore shutdown races. } } ``` Caller đã có `child` reference (từ `activeChildProcesses`), nên truyền xuống. ### Verification ```powershell node --experimental-strip-types --test test/unit/pi-spawn.test.ts test/unit/mock-child-run.test.ts ``` Manual check (Linux/Mac): chạy `node -e "process.exit()"` trong test, đảm bảo không có timer leak qua `process._getActiveHandles()`. ### Rủi ro - Thấp: chỉ thêm clearTimeout khi exit. Behavior không đổi ở fast-exit case (timer vẫn fire nếu child chưa exit). --- ## #7 — `useProjectState` walk-up tìm git root **Priority:** Medium — DX bug trong monorepo. ### Vấn đề - File: `src/state/state-store.ts:21-23` ```ts function useProjectState(cwd: string): boolean { return fs.existsSync(path.join(cwd, ".pi")) || fs.existsSync(path.join(cwd, ".git")); } ``` - Nếu user `cd` vào subfolder của repo (ví dụ `pi-crew/src/`), không tìm thấy `.git` ngay → fallback `~/.pi/agent/extensions/pi-crew/runs/...` → state không phải project-local nữa. - Tương tự: `projectPiRoot(cwd) = path.join(cwd, ".pi")` → `.pi/` được tạo trong subfolder, không phải repo root. ### Đề xuất ```ts // src/utils/paths.ts export function findRepoRoot(cwd: string): string | undefined { let current = path.resolve(cwd); const root = path.parse(current).root; while (current !== root) { if (fs.existsSync(path.join(current, ".git")) || fs.existsSync(path.join(current, ".pi"))) { return current; } const parent = path.dirname(current); if (parent === current) break; current = parent; } return undefined; } export function projectPiRoot(cwd: string): string { return path.join(findRepoRoot(cwd) ?? cwd, ".pi"); } ``` Và `useProjectState`: ```ts function useProjectState(cwd: string): boolean { return findRepoRoot(cwd) !== undefined; } ``` ### Verification ```powershell node --experimental-strip-types --test test/unit/state-store.test.ts test/unit/team-run.test.ts test/unit/discovery.test.ts test/unit/project-init.test.ts ``` Test mới: tạo fixture `tmp/repo/.git/`, chạy `loadConfig(tmp/repo/sub/folder)` → expect path resolve về `tmp/repo/.pi`. ### Rủi ro - **Medium:** Có thể đổi semantics nếu user cố ý dùng subfolder làm pi-crew root. Cần check `discovery.test.ts` và `project-init.test.ts` không assume `cwd === root`. - Workaround: thêm config flag `pi-crew.useGitRoot: false` để giữ behavior cũ. --- ## #8 — Gom hard-coded constants vào `config/defaults.ts` **Priority:** Low — DX/maintainability. ### Vấn đề Các magic numbers rải rác: - `child-pi.ts`: `POST_EXIT_STDIO_GUARD_MS=3000`, `FINAL_DRAIN_MS=5000`, `HARD_KILL_MS=3000`, `MAX_CAPTURE_BYTES=256*1024`, `MAX_ASSISTANT_TEXT_CHARS=8192`, ... - `concurrency.ts`: `defaultWorkflowConcurrency` switch-case. - `event-log.ts`: `TERMINAL_EVENT_TYPES` set. - `state-store.ts`: paths. - `locks.ts`: `DEFAULT_STALE_MS=30_000`. ### Đề xuất Tạo `src/config/defaults.ts`: ```ts export const CrewDefaults = { childPi: { postExitStdioGuardMs: 3000, finalDrainMs: 5000, hardKillMs: 3000, maxCaptureBytes: 256 * 1024, maxAssistantTextChars: 8192, maxToolResultChars: 1024, maxToolInputChars: 2048, maxCompactContentChars: 4096, }, locks: { defaultStaleMs: 30_000, }, concurrency: { workflows: { "parallel-research": 4, research: 2, implementation: 2, review: 2, default: 2 } as Record, fallback: 1, }, ui: { widgetRefreshMs: 1000, sidebarRefreshMs: 1000, }, } as const; ``` Cập nhật từng file thay vì hard-code. Cho phép override qua `loadConfig(cwd).config`: ```ts export function effectiveLimits(config: PiTeamsConfig): typeof CrewDefaults & { /* overrides */ } { return { ...CrewDefaults, childPi: { ...CrewDefaults.childPi, ...(config.runtime?.childPi ?? {}) }, }; } ``` ### Verification ```powershell npx tsc --noEmit node --experimental-strip-types --test # full suite ``` ### Rủi ro - Thấp: chỉ refactor constants. Test phải pass không đổi. --- ## #9 — Validate config bằng TypeBox **Priority:** Low — chuẩn hóa, bắt config invalid sớm. ### Vấn đề - File: `src/extension/team-tool/config-patch.ts` - `configPatchFromConfig` validate manual: ~40 dòng `typeof x === "number" && Number.isInteger(x) && x > 0 ? x : undefined`. - TypeBox đã có cho tool params (`team-tool-schema.ts`), nhưng config schema được load từ `loadConfig` không qua TypeBox — chỉ JSON parse. ### Đề xuất Thêm `src/schema/config-schema.ts`: ```ts import { Type, type Static } from "typebox"; export const PiTeamsLimitsSchema = Type.Object({ maxConcurrentWorkers: Type.Optional(Type.Integer({ minimum: 1 })), maxTaskDepth: Type.Optional(Type.Integer({ minimum: 1 })), // ... }); export const PiTeamsRuntimeSchema = Type.Object({ mode: Type.Optional(Type.Union([Type.Literal("auto"), Type.Literal("scaffold"), Type.Literal("child-process"), Type.Literal("live-session")])), // ... }); export const PiTeamsConfigSchema = Type.Object({ asyncByDefault: Type.Optional(Type.Boolean()), executeWorkers: Type.Optional(Type.Boolean()), limits: Type.Optional(PiTeamsLimitsSchema), runtime: Type.Optional(PiTeamsRuntimeSchema), // ... }); export type PiTeamsConfig = Static; ``` Trong `config.ts`: ```ts import { Value } from "typebox/value"; import { PiTeamsConfigSchema } from "../schema/config-schema.ts"; export function loadConfig(cwd: string): { config: PiTeamsConfig; path: string; error?: string } { const raw = readJsonFile(...); const errors = [...Value.Errors(PiTeamsConfigSchema, raw)]; if (errors.length) { return { config: defaultConfig(), path, error: errors.map(e => `${e.path}: ${e.message}`).join("; ") }; } return { config: Value.Cast(PiTeamsConfigSchema, raw), path }; } ``` ### Verification ```powershell node --experimental-strip-types --test test/unit/config.test.ts test/unit/config-update.test.ts test/unit/project-config.test.ts ``` ### Rủi ro - Medium: thay đổi config validation → invalid config (đang silently bỏ qua) sẽ thành error → cần backward compat (downgrade error → warning hoặc dùng `Value.Cast` để cast best-effort). --- ## #10 — Tách `ensureMailbox` khỏi read path **Priority:** Low — side effect không cần thiết ở read. ### Vấn đề - File: `src/state/mailbox.ts:97-103` - `readMailbox()` luôn gọi `ensureMailbox()` → `mkdirSync` + 4× `writeFileSync` empty + 1× `writeFileSync` delivery.json nếu thiếu. - Read path không nên có side effects. ### Đề xuất ```ts function safeReadMailboxFile(filePath: string, direction: MailboxDirection): MailboxMessage[] { if (!fs.existsSync(filePath)) return []; return readMailboxFile(filePath, direction); } export function readMailbox(manifest: TeamRunManifest, direction?: MailboxDirection, taskId?: string): MailboxMessage[] { // No ensureMailbox here const directions = direction ? [direction] : ["inbox", "outbox"] as const; return directions.flatMap((item) => safeReadMailboxFile(mailboxPath(manifest, item, taskId), item)) .sort((a, b) => a.createdAt.localeCompare(b.createdAt)); } export function appendMailboxMessage(...) { ensureMailbox(manifest, message.taskId); // Only here // ... } ``` ### Verification ```powershell node --experimental-strip-types --test test/unit/mailbox-api.test.ts test/unit/mailbox-validation.test.ts ``` ### Rủi ro - Thấp: append vẫn ensure dir → cấu trúc mailbox luôn được tạo khi cần. --- ## #11 — `injectAdaptivePlanIfReady` chạy ít hơn **Priority:** Low-Medium — performance + log noise. ### Vấn đề - File: `src/runtime/team-runner.ts` - `injectAdaptivePlanIfReady` được gọi 3 lần per scheduler iteration: 1. Initial (line ~244) 2. Mỗi vòng while (line ~268) 3. Sau mỗi batch (line ~308) - Mỗi lần đọc `assess` artifact + parse JSON nếu chưa inject. Đã có guard "tasks.some(adaptive-)" nhưng vẫn execute regex/IO. ### Đề xuất Track flag trong manifest hoặc local state: ```ts let adaptivePlanInjected = tasks.some((task) => task.stepId?.startsWith("adaptive-")); let adaptivePlanFailed = false; // Replace 3 invocations with: function maybeInjectAdaptive() { if (adaptivePlanInjected || adaptivePlanFailed) return; const r = injectAdaptivePlanIfReady({ manifest, tasks, workflow, team: input.team }); if (r.missingPlan) { adaptivePlanFailed = true; /* mark blocked */ } if (r.injected) { adaptivePlanInjected = true; tasks = r.tasks; workflow = r.workflow; } } ``` ### Verification ```powershell node --experimental-strip-types --test test/unit/adaptive-implementation.test.ts test/unit/implementation-fanout.test.ts ``` ### Rủi ro - Thấp: chỉ thay đổi điều kiện trigger, không thay đổi logic inject. --- ## #12 — Bỏ `jiti` khỏi runtime dependencies **Priority:** Low — install size. ### Vấn đề - `package.json` declare `"jiti": "^2.6.1"` trong `dependencies`. - Grep trong source: không có `import.*jiti` nào trong `src/`. ### Đề xuất ```powershell # Verify nothing imports jiti Select-String -Path "D:\my\my_project\pi-crew\src\*","D:\my\my_project\pi-crew\index.ts" -Pattern "jiti" -Recurse ``` Nếu không có hit → remove khỏi `dependencies`: ```json "dependencies": { "typebox": "^1.1.24" } ``` ### Verification ```powershell npm install npx tsc --noEmit npm test npm pack --dry-run ``` ### Rủi ro - Thấp. Nếu dynamic require thì sẽ fail rõ ràng. --- ## #13 — `atomicWriteFile` non-blocking variant **Priority:** Low — chỉ matter trên hot path. ### Vấn đề - File: `src/state/atomic-write.ts:5-9` - `sleepSync` dùng `Atomics.wait` block thread chính: ```ts function sleepSync(ms: number): void { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } ``` - `__test__renameWithRetry` retry up to 20 lần với backoff → có thể block 5+ giây trên main thread (Windows EBUSY/EPERM). ### Đề xuất Thêm async variant cho hot path (saveRunTasks/saveRunManifest trong loop): ```ts export async function atomicWriteFileAsync(filePath: string, content: string): Promise { await fs.promises.mkdir(path.dirname(filePath), { recursive: true }); const tempPath = `${filePath}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`; try { await fs.promises.writeFile(tempPath, content, "utf-8"); await renameWithRetryAsync(tempPath, filePath); } catch (error) { try { await fs.promises.rm(tempPath, { force: true }); } catch {} throw error; } } async function renameWithRetryAsync(tempPath: string, filePath: string, retries = 20): Promise { for (let attempt = 0; attempt <= retries; attempt++) { try { await fs.promises.rename(tempPath, filePath); return; } catch (error) { if (!isRetryableRenameError(error) || attempt === retries) throw error; await new Promise((r) => setTimeout(r, Math.min(250, 10 * 2 ** attempt))); } } } ``` Dùng trong `saveRunTasks`/`saveRunManifest` (gọi từ async context): ```ts export async function saveRunManifestAsync(manifest: TeamRunManifest): Promise { await atomicWriteFileAsync(path.join(manifest.stateRoot, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`); } ``` ### Verification ```powershell node --experimental-strip-types --test test/unit/atomic-write.test.ts test/unit/state-store.test.ts ``` ### Rủi ro - Medium: phải convert call chain sang async → nhiều file thay đổi. Có thể chỉ apply cho 1-2 hot path để tránh ripple. --- ## #14 — `defaultWorkflowConcurrency` đọc từ workflow frontmatter **Priority:** Low — DX. ### Vấn đề - File: `src/runtime/concurrency.ts:18-23` ```ts export function defaultWorkflowConcurrency(workflowName: string): number { if (workflowName === "parallel-research") return 4; if (workflowName === "research") return 2; // ... } ``` - User custom workflow không thể set default concurrency mà phải pass `team.maxConcurrency`. ### Đề xuất `WorkflowConfig` đã có frontmatter loader. Thêm field: ```ts // src/workflows/workflow-config.ts export interface WorkflowConfig { // ... maxConcurrency?: number; } ``` Cập nhật `resolveBatchConcurrency`: ```ts export interface ResolveBatchConcurrencyInput { workflowName: string; workflowMaxConcurrency?: number; // NEW teamMaxConcurrency?: number; limitMaxConcurrentWorkers?: number; readyCount: number; // ... } const requested = limitMax ?? teamMax ?? workflowMax ?? defaultByName ?? 1; ``` Trong `team-runner.ts:executeTeamRun`: ```ts const concurrency = resolveBatchConcurrency({ workflowName: workflow.name, workflowMaxConcurrency: workflow.maxConcurrency, // pass through // ... }); ``` ### Verification ```powershell node --experimental-strip-types --test test/unit/concurrency.test.ts test/unit/parallel-research-dynamic.test.ts test/unit/workflow-validation.test.ts ``` ### Rủi ro - Thấp: thêm optional field, backward compat. --- ## #15 — Logging cho silent catches **Priority:** Low — observability. ### Vấn đề Nhiều `try { ... } catch {}` nuốt lỗi: - `child-pi.ts`: `try { this.input.onJsonEvent?.(event); } catch {}` (line ~165) - `state-store.ts`: lock cleanup `catch {}` - `event-log.ts`: cache update `catch {}` (line ~93) - `team-tool.ts:handleCancel`: `try { saveCrewAgents(...); } catch {}` - `team-tool.ts:handleCancel`: `try { writeForegroundInterruptRequest(...); } catch {}` ### Đề xuất Thêm helper `logInternalError`: ```ts // src/utils/log.ts export function logInternalError(scope: string, error: unknown, eventsPath?: string): void { const message = error instanceof Error ? error.message : String(error); if (process.env.PI_TEAMS_DEBUG) { console.error(`[pi-crew:${scope}] ${message}`); } if (eventsPath) { try { appendEvent(eventsPath, { type: "internal.error", runId: "", message: `${scope}: ${message}` }); } catch {} } } ``` Thay `catch {}` bằng `catch (e) { logInternalError("...", e); }` ở các điểm critical. ### Verification ```powershell $env:PI_TEAMS_DEBUG = "1" node --experimental-strip-types --test test/unit/runtime-hardening.test.ts ``` ### Rủi ro - Thấp: chỉ thêm observability, không thay đổi behavior khi `PI_TEAMS_DEBUG` chưa set. --- ## #16 — Cosmetic & cleanup ### 16a. `tsconfig.json` duplicate include ```json "include": [ "*.ts", "src/**/*.ts", "src/**/*.ts" // <-- duplicate ] ``` Sửa thành: ```json "include": [ "*.ts", "src/**/*.ts", "test/**/*.ts" ] ``` ### 16b. Test folder structure 90 unit tests không phân loại. Đề xuất: ``` test/ unit/ # pure logic (no fs, no spawn) integration/ # spawn child Pi, tạo runs fixtures/ ``` Cập nhật `package.json`: ```json "test:unit": "node --experimental-strip-types --test test/unit/*.test.ts", "test:integration": "node --experimental-strip-types --test test/integration/*.test.ts", "test": "npm run test:unit && npm run test:integration" ``` Move các file `phase[N]-*.test.ts`, `worktree-run.test.ts`, `mock-child-*.test.ts` sang `integration/`. ### 16c. Subagent stuck-blocked notification File: `src/runtime/subagent-manager.ts` - `SubagentManager` callback chỉ trigger khi `completed/failed/cancelled/error`. Status `blocked` (run-level) không trigger. - Đề xuất: khi `record.runId` linked manifest có status `blocked`, tự động gọi callback. ### Verification ```powershell npx tsc --noEmit npm test ``` ### Rủi ro - Thấp. --- ## Thứ tự thực hiện đề xuất 1. **#2** — Lock fix (correctness, multi-process) 2. **#3** — Sequence O(n²) → O(1) (performance) 3. **#4** — Cache loadRunManifestById (UI 1Hz overhead) 4. **#6** — Cleanup child-pi timers (memory leak) 5. **#7** — Walk-up git root (DX bug) 6. **#5** — Memoize task-graph maps (CPU) 7. **#11** — Adaptive plan trigger optimization 8. **#10** — ensureMailbox khỏi read path 9. **#8** — Gom constants 10. **#13** — Async atomic write 11. **#14** — Workflow.maxConcurrency 12. **#9** — TypeBox validate config 13. **#12** — Drop jiti 14. **#15** — Internal error logging 15. **#16** — Cosmetic --- ## Quy ước test cho mỗi task Theo workflow `~/.factory/AGENTS.md` mục 11: ```powershell # Sau mỗi thay đổi: Set-Location D:\my\my_project\pi-crew npx tsc --noEmit # Type check node --experimental-strip-types --test test/unit/.test.ts # Targeted tests npm test # Full suite (nếu thay đổi module core) ``` PR template (tham khảo `~/.factory/AGENTS.md` mục 10): ``` Summary: Plan: ... Files & Rationale: ... Tests: ... Verification: - npx tsc --noEmit → Passed - node --experimental-strip-types --test ... → N pass Risks & Rollback: ... ``` --- ## Phase 2 — Follow-up Tasks (sau review #2–#16) > Phát hiện trong review ngày 28/04/2026 sau khi các task #2–#16 đã hoàn thành. Đây là các vấn đề lộ ra do fix tsconfig (#15) và một số chỗ chưa hoàn thiện. ### Trạng thái Phase 2 - [x] Task #17 — Fix 71 TS errors trong test files (CRITICAL) - [x] Task #18 — LRU bound cho `manifestCache` (MEDIUM) - [x] Task #19 — Cross-process cache staleness check (MEDIUM) - [x] Task #20 — Tách `ensureMailbox` (LOW) - [x] Task #21 — Giảm circular import giữa `team-tool.ts` ↔ `tool-result.ts` (đã fix trong review) - [x] Task #22 — Codemod `TeamContext` import (LOW) - [x] Task #23 — Subagent stuck-blocked notification (LOW) - [x] Task #24 — TypeBox config validation warnings (MEDIUM) - [x] Task #25 — `atomicWriteFileAsync` idempotent retry (LOW) ### Thứ tự thực hiện đề xuất 1. **#17** ✅ (CRITICAL — chặn CI) → hoàn thành 2. **#18** + **#19** (MEDIUM — cùng file `state-store.ts`, gộp 1 PR) 3. **#24** (MEDIUM — UX cải thiện rõ ràng) 4. **#20** + **#22** (LOW — refactor cosmetic) 5. **#23** (LOW — feature mới) 6. **#25** (LOW — edge case hiếm) --- ### Task #17 — Fix 71 TypeScript errors trong test files (CRITICAL) **Vấn đề:** Sau khi `tsconfig.json` được sửa để include `test/**/*.ts` (#15), 71 lỗi type pre-existing lộ ra. `src/` không có lỗi nào — toàn bộ ở `test/`. Tests vẫn chạy pass nhờ `node --experimental-strip-types` xoá type ở runtime, nhưng `npm run typecheck` (CI) sẽ fail. **Nguyên nhân:** `AgentToolResult.content` có kiểu `(TextContent | ImageContent)[]`. Test cũ dùng `result.content[0]?.text ?? ""` không hợp lệ vì `ImageContent` không có field `text`. Trước đây tests không bị typecheck nên không phát hiện. **Vị trí:** ~32 file trong `test/unit/` và `test/integration/` (số trong ngoặc = số lỗi): ``` test/integration/phase5-observability.test.ts (6) test/integration/phase6-control.test.ts (2) test/integration/worktree-run.test.ts (2) test/unit/agent-runtime-files.test.ts (5) test/unit/api-claim.test.ts (1) test/unit/api-locks.test.ts (1) test/unit/async-stale.test.ts (2) test/unit/autonomy-config.test.ts (3) test/unit/config-action.test.ts (1) test/unit/crew-gap-lessons.test.ts (4) test/unit/cross-extension-rpc.test.ts (1) test/unit/doctor-smoke.test.ts (1) test/unit/doctor-validation.test.ts (4) test/unit/foreground-nonblocking.test.ts (1) test/unit/help.test.ts (1) test/unit/import-list.test.ts (1) test/unit/lazy-agent-materialization.test.ts (1) test/unit/live-agent-control.test.ts (2) test/unit/live-control-realtime.test.ts (1) test/unit/live-session-context.test.ts (3) test/unit/live-session-runtime.test.ts (4) test/unit/mailbox-api.test.ts (4) test/unit/mailbox-validation.test.ts (1) test/unit/management-references.test.ts (1) test/unit/project-init.test.ts (1) test/unit/run-events-artifacts.test.ts (4) test/unit/runtime-hardening.test.ts (2) test/unit/subagent-manager.test.ts (5) test/unit/summary.test.ts (2) test/unit/team-recommendation.test.ts (1) test/unit/team-run.test.ts (2) test/unit/validate-resources.test.ts (1) ``` **Đề xuất fix:** **Bước 1 — Tạo helper** trong `test/fixtures/tool-result-helpers.ts`: ```ts export function textOf(result: { content?: Array<{ type: string; text?: string }> }): string { return result.content ?.filter((item): item is { type: "text"; text: string } => item.type === "text" && typeof item.text === "string") .map((item) => item.text) .join("\n") ?? ""; } export function firstText(result: { content?: Array<{ type: string; text?: string }> }): string { const first = result.content?.find((item) => item.type === "text" && typeof item.text === "string"); return first?.text ?? ""; } ``` **Bước 2 — Codemod** thay `result.content[0]?.text ?? ""` → `firstText(result)`: ```powershell Get-ChildItem D:\my\my_project\pi-crew\test -Recurse -Filter *.test.ts | ForEach-Object { $content = Get-Content $_.FullName -Raw $new = $content -replace '(\w+)\.content\[0\]\?\.text \?\? ""', 'firstText($1)' if ($new -ne $content) { Set-Content $_.FullName $new -NoNewline } } ``` Sau đó thêm `import { firstText } from "../fixtures/tool-result-helpers.ts";` vào mỗi file đã sửa. **Bước 3 — Sửa riêng** 5 lỗi trong `test/unit/subagent-manager.test.ts` (mock `SpawnRunner` trả `status: string`): ```ts // Cũ const runner = async () => ({ content: [...], details: { action: "x", status: "ok" } }); // Mới const runner: SpawnRunner = async () => ({ content: [...], details: { action: "x", status: "ok" as const } }); ``` **Bước 4 — Sửa** 2 lỗi `cross-extension-rpc.test.ts(37)` & `live-control-realtime.test.ts(18)` — `setTimeout(...)` trả `number` không match `() => void | Promise`. Bọc: ```ts // Cũ setTimeout(() => doSomething(), 100); // Mới () => { setTimeout(() => doSomething(), 100); } ``` **Bước 5 — Sửa** `test/unit/live-session-context.test.ts(27)`: mock object thiếu các field bắt buộc của `TeamContext`. Thêm `as TeamContext` cast hoặc bổ sung field. **Verification:** ```bash npx tsc --noEmit # → 0 errors npm test # → all pass npm run ci # → typecheck + test + pack OK ``` **Risk:** Thấp — thuần test code, không ảnh hưởng runtime. Chạy `git diff test/` review trước khi commit để chắc codemod không thay nhầm. --- ### Task #18 — LRU bound cho manifestCache (MEDIUM) **Vấn đề:** `manifestCache` trong `src/state/state-store.ts:29` là `Map` không có giới hạn. Trong long-running session với nhiều run (status query liên tục), Map có thể grow vô hạn → memory leak. **Vị trí:** `src/state/state-store.ts:29, 206` **Đề xuất fix:** ```ts // config/defaults.ts export const DEFAULT_CACHE = { manifestMaxEntries: 64, }; // state-store.ts import { DEFAULT_CACHE } from "../config/defaults.ts"; const manifestCache = new Map(); function setManifestCache(stateRoot: string, entry: ManifestCacheEntry): void { if (manifestCache.has(stateRoot)) manifestCache.delete(stateRoot); // refresh recency manifestCache.set(stateRoot, entry); while (manifestCache.size > DEFAULT_CACHE.manifestMaxEntries) { const oldest = manifestCache.keys().next().value; if (!oldest) break; manifestCache.delete(oldest); } } // Trong loadRunManifestById, đổi: // manifestCache.set(stateRoot, { ... }); // thành: // setManifestCache(stateRoot, { ... }); ``` **Verification:** ```bash node --experimental-strip-types --test test/unit/state-store.test.ts # Thêm test mới: load 100 run khác nhau, verify manifestCache.size <= 64 ``` **Risk:** Thấp — chỉ ảnh hưởng cache, không ảnh hưởng đúng đắn vì mtime invalidation vẫn đảm bảo data fresh. --- ### Task #19 — Cross-process cache staleness check (MEDIUM) **Vấn đề:** `manifestCache` invalidate dựa trên `manifestStat.mtimeMs`. Trên Windows, mtime granularity ~1ms. Nếu process A ghi manifest tại t=0ms và process B đọc cùng lúc với cache cũ tại t=1ms, mtime có thể trùng → cache stale. **Vị trí:** `src/state/state-store.ts:175-208` (`loadRunManifestById`) **Đề xuất fix:** Kết hợp mtime + size (cheap): ```ts interface ManifestCacheEntry { manifest: TeamRunManifest; tasks: TeamTaskState[]; manifestMtimeMs: number; manifestSize: number; // <-- thêm tasksMtimeMs: number; tasksSize: number; // <-- thêm } // Validate if (cached && cached.manifestMtimeMs === manifestStat.mtimeMs && cached.manifestSize === manifestStat.size && cached.tasksMtimeMs === tasksMtimeMs && cached.tasksSize === (tasksStat?.size ?? 0)) { return { manifest: cached.manifest, tasks: cached.tasks }; } // Khi cache: manifestCache.set(stateRoot, { manifest, tasks, manifestMtimeMs: manifestStat.mtimeMs, manifestSize: manifestStat.size, tasksMtimeMs, tasksSize: tasksStat?.size ?? 0, }); ``` **Verification:** ```bash node --experimental-strip-types --test test/unit/state-store.test.ts # Test mới: write manifest 2 lần liên tiếp với content khác nhau cùng mtime giả định, verify load lần 2 không trả cached ``` **Risk:** Thấp — chỉ thắt chặt validation, không loại trừ cache hit hợp lệ. --- ### Task #20 — Tách ensureMailbox thành 2 hàm rõ ràng (LOW) **Vấn đề:** `ensureMailbox(manifest, taskId?)` trong `src/state/mailbox.ts:54-62` xử lý cả run-level và task-level. Dòng 60 gọi `mkdirSync(mailboxDir(manifest), ...)` lặp lại vì task path đã chứa run path. Code khó đọc, dễ regress khi thêm scope mới. **Vị trí:** `src/state/mailbox.ts:54-62` **Đề xuất fix:** ```ts function ensureRunMailbox(manifest: TeamRunManifest): void { fs.mkdirSync(mailboxDir(manifest), { recursive: true }); for (const direction of ["inbox", "outbox"] as const) { const filePath = mailboxPath(manifest, direction); if (!fs.existsSync(filePath)) fs.writeFileSync(filePath, "", "utf-8"); } const delivery = deliveryPath(manifest); if (!fs.existsSync(delivery)) { fs.writeFileSync(delivery, `${JSON.stringify({ messages: {}, updatedAt: new Date().toISOString() }, null, 2)}\n`, "utf-8"); } } function ensureTaskMailbox(manifest: TeamRunManifest, taskId: string): void { ensureRunMailbox(manifest); // task-level cần delivery.json ở run-level fs.mkdirSync(taskMailboxDir(manifest, taskId), { recursive: true }); for (const direction of ["inbox", "outbox"] as const) { const filePath = mailboxPath(manifest, direction, taskId); if (!fs.existsSync(filePath)) fs.writeFileSync(filePath, "", "utf-8"); } } // Update tất cả call sites: // ensureMailbox(manifest) → ensureRunMailbox(manifest) // ensureMailbox(manifest, taskId) → ensureTaskMailbox(manifest, taskId) ``` **Verification:** ```bash node --experimental-strip-types --test test/unit/mailbox-api.test.ts test/unit/mailbox-validation.test.ts ``` **Risk:** Thấp — refactor thuần, behavior không đổi. --- ### Task #21 — ✅ ĐÃ HOÀN THÀNH trong review Giảm circular import giữa `team-tool.ts` ↔ `tool-result.ts`: - `src/extension/tool-result.ts` — đổi `import type { TeamToolDetails } from "./team-tool.ts"` → `from "./team-tool-types.ts"` - `src/extension/management.ts` — tương tự Cần verify thêm chưa có chỗ nào còn import xấu: ```powershell Select-String -Path src\extension\*.ts,src\extension\**\*.ts -Pattern 'TeamToolDetails.*from "(\.\.?/)*team-tool\.ts"' ``` Nếu còn match → đổi sang `team-tool-types.ts`. --- ### Task #22 — Codemod TeamContext import rời khỏi team-tool.ts (LOW) **Vấn đề:** `team-tool.ts:40` re-export `TeamContext` từ `./team-tool/context.ts`. Các file khác import qua `team-tool.ts` tạo dependency chain dài. Nên import trực tiếp từ `./team-tool/context.ts` để rõ chuỗi dependency. **Vị trí:** Search: ```powershell Select-String -Path src\**\*.ts -Pattern 'TeamContext.*from "(\.\.?/)*extension/team-tool\.ts"' ``` **Đề xuất fix:** Đổi import sang `team-tool/context.ts`. Có thể giữ re-export ở `team-tool.ts` cho backward compat ngoài (extension API public). **Verification:** ```bash npx tsc --noEmit npm test ``` **Risk:** Thấp — refactor pure. --- ### Task #23 — Subagent stuck-blocked notification (LOW, từ #16c Phase 1) **Vấn đề:** `subagent-manager.ts` có status `"blocked"` trong `TERMINAL_RUN_STATUS` nhưng không có notification UI khi child run blocked > N phút. User không biết child đang stuck. **Vị trí:** `src/runtime/subagent-manager.ts` **Đề xuất fix:** 1. Thêm constant trong `config/defaults.ts`: ```ts export const DEFAULT_SUBAGENT = { stuckBlockedNotifyMs: 5 * 60_000, // 5 phút }; ``` 2. Bổ sung field vào `SubagentRecord`: ```ts export interface SubagentRecord { // ... existing stuckNotified?: boolean; } ``` 3. Trong polling/watch loop kiểm tra child status: ```ts import { DEFAULT_SUBAGENT } from "../config/defaults.ts"; if (record.status === "blocked" && record.startedAt && Date.now() - record.startedAt > DEFAULT_SUBAGENT.stuckBlockedNotifyMs && !record.stuckNotified) { emitEvent("subagent.stuck-blocked", { id: record.id, runId: record.runId, durationMs: Date.now() - record.startedAt, }); record.stuckNotified = true; savePersistedSubagentRecord(cwd, record); } ``` 4. UI/dashboard subscribe event `subagent.stuck-blocked` hiển thị badge cảnh báo. **Verification:** Test mới `test/unit/subagent-stuck-notify.test.ts`: ```ts test("subagent blocked > threshold emits stuck-blocked event", async () => { const record = createRecord({ status: "blocked", startedAt: Date.now() - 10 * 60_000 }); const events: string[] = []; checkSubagentStuck(record, (type) => events.push(type)); assert.ok(events.includes("subagent.stuck-blocked")); assert.equal(record.stuckNotified, true); }); ``` **Risk:** Thấp — feature mới, không ảnh hưởng path hiện có. --- ### Task #24 — TypeBox config validation warnings (MEDIUM) **Vấn đề:** `config.ts` `parseConfig()` dùng `parseWithSchema` trả `undefined` khi schema fail → silent drop. User config sai sẽ bị bỏ qua không cảnh báo. **Vị trí:** `src/config/config.ts:189-207, parseConfig()` **Đề xuất fix:** 1. Thêm hàm `validateConfigStrict` trả về warnings: ```ts import { Value } from "typebox/value"; import { PiTeamsConfigSchema } from "../schema/config-schema.ts"; export interface ConfigValidation { config: PiTeamsConfig; warnings: string[]; } export function validateConfigStrict(raw: unknown): ConfigValidation { const warnings: string[] = []; if (raw && typeof raw === "object" && !Value.Check(PiTeamsConfigSchema, raw)) { for (const err of Value.Errors(PiTeamsConfigSchema, raw)) { warnings.push(`Config invalid at ${err.path}: ${err.message}`); } } return { config: parseConfig(raw), warnings }; } ``` 2. Thêm field `warnings` vào `LoadedPiTeamsConfig`: ```ts export interface LoadedPiTeamsConfig { config: PiTeamsConfig; path: string; paths: string[]; error?: string; warnings?: string[]; // <-- thêm } ``` 3. `loadConfig()` populate warnings từ cả user + project config: ```ts export function loadConfig(cwd?: string): LoadedPiTeamsConfig { const filePath = configPath(); const paths = cwd ? [filePath, projectConfigPath(cwd)] : [filePath]; const warnings: string[] = []; try { const userValidation = validateConfigStrict(readConfigRecord(filePath)); warnings.push(...userValidation.warnings.map((w) => `${filePath}: ${w}`)); let config = userValidation.config; if (cwd) { const projectValidation = validateConfigStrict(readConfigRecord(projectConfigPath(cwd))); warnings.push(...projectValidation.warnings.map((w) => `${projectConfigPath(cwd)}: ${w}`)); config = mergeConfig(config, projectValidation.config); } return { path: filePath, paths, config, warnings: warnings.length ? warnings : undefined }; } catch (error) { const message = error instanceof Error ? error.message : String(error); return { path: filePath, paths, config: {}, error: message }; } } ``` 4. UI hiển thị warnings trong action `team doctor` (`handleDoctor`). **Verification:** ```bash node --experimental-strip-types --test test/unit/config-schema-validation.test.ts # Thêm test: // - invalid config (e.g., notifierIntervalMs: 100) → warnings non-empty // - valid config → warnings undefined ``` **Risk:** Thấp — chỉ thêm thông tin, không thay đổi runtime behavior. Backward-compat: nếu callers không đọc `warnings` thì không ảnh hưởng. --- ### Task #25 — atomicWriteFileAsync idempotent retry (LOW) **Vấn đề:** `atomicWriteFileAsync` ghi temp + rename. Nếu 2 process song song write cùng `filePath`, đôi khi rename của process A vào lúc process B đã rename xong → `EPERM`/`EBUSY` Windows. Retry handle nhưng có thể spin nhiều lần không cần thiết. **Vị trí:** `src/state/atomic-write.ts:34-46` **Đề xuất fix:** Sau retry exhaust, kiểm tra nếu file đã tồn tại với content khớp mong muốn → coi như success (idempotent write): ```ts export async function atomicWriteFileAsync(filePath: string, content: string): Promise { await fs.promises.mkdir(path.dirname(filePath), { recursive: true }); const tempPath = `${filePath}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`; try { await fs.promises.writeFile(tempPath, content, "utf-8"); try { await __test__renameWithRetryAsync(tempPath, filePath); } catch (renameError) { // Idempotent fallback: nếu file đã có nội dung khớp → success try { const existing = await fs.promises.readFile(filePath, "utf-8"); if (existing === content) { await fs.promises.rm(tempPath, { force: true }); return; } } catch { // file không tồn tại hoặc không đọc được → throw original } throw renameError; } } catch (error) { try { await fs.promises.rm(tempPath, { force: true }); } catch (cleanupError) { logInternalError("atomic-write.cleanupAsync", cleanupError, `tempPath=${tempPath}`); } throw error; } } ``` **Verification:** Stress test mới `test/unit/atomic-write-concurrent.test.ts`: ```ts test("100 concurrent writes of same content succeed", async () => { const filePath = path.join(tmpDir, "concurrent.json"); await Promise.all( Array.from({ length: 100 }, () => atomicWriteFileAsync(filePath, '{"v":1}')) ); assert.equal(fs.readFileSync(filePath, "utf-8"), '{"v":1}'); }); ``` **Risk:** Trung bình — race rất hiếm, ưu tiên sau cùng. Cần test kỹ trên Windows + Linux để chắc fallback không hide bug. --- ## Quick Reference — Verification cho mọi follow-up task ```powershell Set-Location D:\my\my_project\pi-crew # Type check (PHẢI pass sau task #17) npx tsc --noEmit # Targeted tests node --experimental-strip-types --test test/unit/.test.ts # Full unit suite npm run test:unit # Full CI npm run ci # = typecheck + test + npm pack --dry-run ```