feat: enhance workflow and testing infrastructure

- Introduced ExecutionMode type for project execution modes (parallel, sequential).
- Updated SetupOpts to use ExecutionMode instead of string literals.
- Enhanced workflow states to include a new "In Review" state with appropriate transitions.
- Implemented TestHarness for end-to-end testing, including command interception and workspace setup.
- Created TestProvider for in-memory issue tracking during tests.
- Refactored project registration and setup tools to utilize ExecutionMode.
- Updated various tools to ensure compatibility with new workflow and execution modes.
- Added new dependencies: cockatiel for resilience and zod for schema validation.
This commit is contained in:
Lauren ten Hoor
2026-02-16 13:27:14 +08:00
parent a359ffed34
commit 371e760d94
37 changed files with 2444 additions and 263 deletions

View File

@@ -1,12 +1,61 @@
/**
* Atomic projects.json read/write operations.
* All state mutations go through this module to prevent corruption.
*
* Uses file-level locking to prevent concurrent read-modify-write races.
*/
import fs from "node:fs/promises";
import path from "node:path";
import { homedir } from "node:os";
import { migrateProject } from "./migrations.js";
import { ensureWorkspaceMigrated, DATA_DIR } from "./setup/migrate-layout.js";
import type { ExecutionMode } from "./workflow.js";
// ---------------------------------------------------------------------------
// File locking — prevents concurrent read-modify-write races
// ---------------------------------------------------------------------------
const LOCK_STALE_MS = 30_000;
const LOCK_RETRY_MS = 50;
const LOCK_TIMEOUT_MS = 10_000;
function lockPath(workspaceDir: string): string {
return projectsPath(workspaceDir) + ".lock";
}
async function acquireLock(workspaceDir: string): Promise<void> {
const lock = lockPath(workspaceDir);
const deadline = Date.now() + LOCK_TIMEOUT_MS;
while (Date.now() < deadline) {
try {
await fs.writeFile(lock, String(Date.now()), { flag: "wx" });
return;
} catch (err: any) {
if (err.code !== "EEXIST") throw err;
// Check for stale lock
try {
const content = await fs.readFile(lock, "utf-8");
const lockTime = Number(content);
if (Date.now() - lockTime > LOCK_STALE_MS) {
try { await fs.unlink(lock); } catch { /* race */ }
continue;
}
} catch { /* lock disappeared — retry */ }
await new Promise((r) => setTimeout(r, LOCK_RETRY_MS));
}
}
// Last resort: force remove potentially stale lock
try { await fs.unlink(lockPath(workspaceDir)); } catch { /* ignore */ }
await fs.writeFile(lock, String(Date.now()), { flag: "wx" });
}
async function releaseLock(workspaceDir: string): Promise<void> {
try { await fs.unlink(lockPath(workspaceDir)); } catch { /* already removed */ }
}
export type WorkerState = {
active: boolean;
@@ -28,7 +77,7 @@ export type Project = {
/** Issue tracker provider type (github or gitlab). Auto-detected at registration, stored for reuse. */
provider?: "github" | "gitlab";
/** Project-level role execution: parallel (DEVELOPER+TESTER can run simultaneously) or sequential (only one role at a time). Default: parallel */
roleExecution?: "parallel" | "sequential";
roleExecution?: ExecutionMode;
maxDevWorkers?: number;
maxQaWorkers?: number;
/** Worker state per role (developer, tester, architect, or custom roles). */
@@ -109,6 +158,7 @@ export function getWorker(
/**
* Update worker state for a project. Only provided fields are updated.
* Sessions are merged (not replaced) when both existing and new sessions are present.
* Uses file locking to prevent concurrent read-modify-write races.
*/
export async function updateWorker(
workspaceDir: string,
@@ -116,22 +166,27 @@ export async function updateWorker(
role: string,
updates: Partial<WorkerState>,
): Promise<ProjectsData> {
const data = await readProjects(workspaceDir);
const project = data.projects[groupId];
if (!project) {
throw new Error(`Project not found for groupId: ${groupId}`);
await acquireLock(workspaceDir);
try {
const data = await readProjects(workspaceDir);
const project = data.projects[groupId];
if (!project) {
throw new Error(`Project not found for groupId: ${groupId}`);
}
const worker = project.workers[role] ?? emptyWorkerState([]);
if (updates.sessions && worker.sessions) {
updates.sessions = { ...worker.sessions, ...updates.sessions };
}
project.workers[role] = { ...worker, ...updates };
await writeProjects(workspaceDir, data);
return data;
} finally {
await releaseLock(workspaceDir);
}
const worker = project.workers[role] ?? emptyWorkerState([]);
if (updates.sessions && worker.sessions) {
updates.sessions = { ...worker.sessions, ...updates.sessions };
}
project.workers[role] = { ...worker, ...updates };
await writeProjects(workspaceDir, data);
return data;
}
/**