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

@@ -33,36 +33,61 @@ export function parseDevClawSessionKey(
return { projectName: match[1], role: match[2] };
}
/**
* Result of loading role instructions — includes the source for traceability.
*/
export type RoleInstructionsResult = {
content: string;
/** Which file the instructions were loaded from, or null if none found. */
source: string | null;
};
/**
* Load role-specific instructions from workspace.
* Tries project-specific file first, then falls back to default.
* Returns both the content and the source path for logging/traceability.
*
* This is the same logic previously in dispatch.ts loadRoleInstructions(),
* now called from the bootstrap hook instead of during dispatch.
* Resolution order:
* 1. devclaw/projects/<project>/prompts/<role>.md (project-specific)
* 2. projects/roles/<project>/<role>.md (old project-specific)
* 3. devclaw/prompts/<role>.md (workspace default)
* 4. projects/roles/default/<role>.md (old default)
*/
export async function loadRoleInstructions(
workspaceDir: string,
projectName: string,
role: string,
): Promise<string> {
): Promise<string>;
export async function loadRoleInstructions(
workspaceDir: string,
projectName: string,
role: string,
opts: { withSource: true },
): Promise<RoleInstructionsResult>;
export async function loadRoleInstructions(
workspaceDir: string,
projectName: string,
role: string,
opts?: { withSource: true },
): Promise<string | RoleInstructionsResult> {
const dataDir = path.join(workspaceDir, DATA_DIR);
// Project-specific: devclaw/projects/<project>/prompts/<role>.md
const projectFile = path.join(dataDir, "projects", projectName, "prompts", `${role}.md`);
try { return await fs.readFile(projectFile, "utf-8"); } catch { /* not found */ }
const candidates = [
path.join(dataDir, "projects", projectName, "prompts", `${role}.md`),
path.join(workspaceDir, "projects", "roles", projectName, `${role}.md`),
path.join(dataDir, "prompts", `${role}.md`),
path.join(workspaceDir, "projects", "roles", "default", `${role}.md`),
];
// Fallback old path: projects/roles/<project>/<role>.md
const oldProjectFile = path.join(workspaceDir, "projects", "roles", projectName, `${role}.md`);
try { return await fs.readFile(oldProjectFile, "utf-8"); } catch { /* not found */ }
// Default: devclaw/prompts/<role>.md
const defaultFile = path.join(dataDir, "prompts", `${role}.md`);
try { return await fs.readFile(defaultFile, "utf-8"); } catch { /* not found */ }
// Fallback old default: projects/roles/default/<role>.md
const oldDefaultFile = path.join(workspaceDir, "projects", "roles", "default", `${role}.md`);
try { return await fs.readFile(oldDefaultFile, "utf-8"); } catch { /* not found */ }
for (const filePath of candidates) {
try {
const content = await fs.readFile(filePath, "utf-8");
if (opts?.withSource) return { content, source: filePath };
return content;
} catch { /* not found, try next */ }
}
if (opts?.withSource) return { content: "", source: null };
return "";
}
@@ -102,25 +127,26 @@ export function registerBootstrapHook(api: OpenClawPluginApi): void {
const bootstrapFiles = context.bootstrapFiles;
if (!Array.isArray(bootstrapFiles)) return;
const instructions = await loadRoleInstructions(
const { content, source } = await loadRoleInstructions(
workspaceDir,
parsed.projectName,
parsed.role,
{ withSource: true },
);
if (!instructions) return;
if (!content) return;
// Inject as a virtual bootstrap file. OpenClaw includes these in the
// agent's system prompt automatically (via buildBootstrapContextFiles).
bootstrapFiles.push({
name: "WORKER_INSTRUCTIONS.md" as any,
path: `<devclaw:${parsed.projectName}:${parsed.role}>`,
content: instructions.trim(),
content: content.trim(),
missing: false,
});
api.logger.info(
`Bootstrap hook: injected ${parsed.role} instructions for project "${parsed.projectName}"`,
`Bootstrap hook: injected ${parsed.role} instructions for project "${parsed.projectName}" from ${source}`,
);
});
}

View File

@@ -8,7 +8,10 @@ export type {
RoleOverride,
ResolvedConfig,
ResolvedRoleConfig,
ResolvedTimeouts,
TimeoutConfig,
} from "./types.js";
export { loadConfig } from "./loader.js";
export { mergeConfig } from "./merge.js";
export { validateConfig, validateWorkflowIntegrity } from "./schema.js";

View File

@@ -14,7 +14,8 @@ import YAML from "yaml";
import { ROLE_REGISTRY } from "../roles/registry.js";
import { DEFAULT_WORKFLOW, type WorkflowConfig } from "../workflow.js";
import { mergeConfig } from "./merge.js";
import type { DevClawConfig, ResolvedConfig, ResolvedRoleConfig, RoleOverride } from "./types.js";
import type { DevClawConfig, ResolvedConfig, ResolvedRoleConfig, ResolvedTimeouts, RoleOverride } from "./types.js";
import { validateConfig, validateWorkflowIntegrity } from "./schema.js";
import { DATA_DIR } from "../setup/migrate-layout.js";
/**
@@ -140,20 +141,42 @@ function resolve(config: DevClawConfig): ResolvedConfig {
states: { ...DEFAULT_WORKFLOW.states, ...config.workflow?.states },
};
return { roles, workflow };
// Validate structural integrity (cross-references between states)
const integrityErrors = validateWorkflowIntegrity(workflow);
if (integrityErrors.length > 0) {
throw new Error(`Workflow config integrity errors:\n - ${integrityErrors.join("\n - ")}`);
}
const timeouts: ResolvedTimeouts = {
gitPullMs: config.timeouts?.gitPullMs ?? 30_000,
gatewayMs: config.timeouts?.gatewayMs ?? 15_000,
sessionPatchMs: config.timeouts?.sessionPatchMs ?? 30_000,
dispatchMs: config.timeouts?.dispatchMs ?? 600_000,
staleWorkerHours: config.timeouts?.staleWorkerHours ?? 2,
};
return { roles, workflow, timeouts };
}
// ---------------------------------------------------------------------------
// File reading helpers
// ---------------------------------------------------------------------------
/** Read workflow.yaml (new primary config file). */
/** Read workflow.yaml (new primary config file). Validates structure via Zod. */
async function readWorkflowFile(dir: string): Promise<DevClawConfig | null> {
try {
const content = await fs.readFile(path.join(dir, "workflow.yaml"), "utf-8");
return YAML.parse(content) as DevClawConfig;
} catch { /* not found */ }
return null;
const parsed = YAML.parse(content);
if (parsed) validateConfig(parsed);
return parsed as DevClawConfig;
} catch (err: any) {
if (err?.code === "ENOENT") return null;
// Re-throw validation errors with file context
if (err?.name === "ZodError") {
throw new Error(`Invalid workflow.yaml in ${dir}: ${err.message}`);
}
return null;
}
}
/** Read config.yaml (old name, fallback for unmigrated workspaces). */

View File

@@ -57,6 +57,11 @@ export function mergeConfig(
}
}
// Merge timeouts
if (base.timeouts || overlay.timeouts) {
merged.timeouts = { ...base.timeouts, ...overlay.timeouts };
}
return merged;
}

114
lib/config/schema.ts Normal file
View File

@@ -0,0 +1,114 @@
/**
* config/schema.ts — Zod validation for DevClaw workflow config.
*
* Validates workflow YAML at load time with clear error messages.
* Enforces: transition targets exist, queue states have roles,
* terminal states have no outgoing transitions.
*/
import { z } from "zod";
import { StateType } from "../workflow.js";
const STATE_TYPES = Object.values(StateType) as [string, ...string[]];
const TransitionTargetSchema = z.union([
z.string(),
z.object({
target: z.string(),
actions: z.array(z.string()).optional(),
description: z.string().optional(),
}),
]);
const StateConfigSchema = z.object({
type: z.enum(STATE_TYPES),
role: z.string().optional(),
label: z.string(),
color: z.string(),
priority: z.number().optional(),
description: z.string().optional(),
check: z.string().optional(),
on: z.record(z.string(), TransitionTargetSchema).optional(),
});
const WorkflowConfigSchema = z.object({
initial: z.string(),
states: z.record(z.string(), StateConfigSchema),
});
const RoleOverrideSchema = z.union([
z.literal(false),
z.object({
levels: z.array(z.string()).optional(),
defaultLevel: z.string().optional(),
models: z.record(z.string(), z.string()).optional(),
emoji: z.record(z.string(), z.string()).optional(),
completionResults: z.array(z.string()).optional(),
}),
]);
const TimeoutConfigSchema = z.object({
gitPullMs: z.number().positive().optional(),
gatewayMs: z.number().positive().optional(),
sessionPatchMs: z.number().positive().optional(),
dispatchMs: z.number().positive().optional(),
staleWorkerHours: z.number().positive().optional(),
}).optional();
export const DevClawConfigSchema = z.object({
roles: z.record(z.string(), RoleOverrideSchema).optional(),
workflow: WorkflowConfigSchema.partial().optional(),
timeouts: TimeoutConfigSchema,
});
/**
* Validate a raw parsed config object.
* Returns the validated config or throws with a descriptive error.
*/
export function validateConfig(raw: unknown): void {
DevClawConfigSchema.parse(raw);
}
/**
* Validate structural integrity of a fully-resolved workflow config.
* Checks cross-references that Zod schema alone can't enforce:
* - All transition targets point to existing states
* - Queue states have a role assigned
* - Terminal states have no outgoing transitions
*/
export function validateWorkflowIntegrity(
workflow: { initial: string; states: Record<string, { type: string; role?: string; on?: Record<string, unknown> }> },
): string[] {
const errors: string[] = [];
const stateKeys = new Set(Object.keys(workflow.states));
if (!stateKeys.has(workflow.initial)) {
errors.push(`Initial state "${workflow.initial}" does not exist in states`);
}
for (const [key, state] of Object.entries(workflow.states)) {
if (state.type === StateType.QUEUE && !state.role) {
errors.push(`Queue state "${key}" must have a role assigned`);
}
if (state.type === StateType.ACTIVE && !state.role) {
errors.push(`Active state "${key}" must have a role assigned`);
}
if (state.type === StateType.TERMINAL && state.on && Object.keys(state.on).length > 0) {
errors.push(`Terminal state "${key}" should not have outgoing transitions`);
}
if (state.on) {
for (const [event, transition] of Object.entries(state.on)) {
const target = typeof transition === "string"
? transition
: (transition as { target: string }).target;
if (!stateKeys.has(target)) {
errors.push(`State "${key}" transition "${event}" targets non-existent state "${target}"`);
}
}
}
}
return errors;
}

View File

@@ -18,6 +18,18 @@ export type RoleOverride = {
completionResults?: string[];
};
/**
* Configurable timeout values (in milliseconds).
* All fields optional — defaults applied at resolution time.
*/
export type TimeoutConfig = {
gitPullMs?: number;
gatewayMs?: number;
sessionPatchMs?: number;
dispatchMs?: number;
staleWorkerHours?: number;
};
/**
* The full workflow.yaml shape.
* All fields optional — missing fields inherit from the layer below.
@@ -25,6 +37,18 @@ export type RoleOverride = {
export type DevClawConfig = {
roles?: Record<string, RoleOverride | false>;
workflow?: Partial<WorkflowConfig>;
timeouts?: TimeoutConfig;
};
/**
* Fully resolved timeout config — all fields present with defaults.
*/
export type ResolvedTimeouts = {
gitPullMs: number;
gatewayMs: number;
sessionPatchMs: number;
dispatchMs: number;
staleWorkerHours: number;
};
/**
@@ -34,6 +58,7 @@ export type DevClawConfig = {
export type ResolvedConfig = {
roles: Record<string, ResolvedRoleConfig>;
workflow: WorkflowConfig;
timeouts: ResolvedTimeouts;
};
/**

View File

@@ -151,6 +151,7 @@ export async function dispatchTask(
const resolvedConfig = await loadConfig(workspaceDir, project.name);
const resolvedRole = resolvedConfig.roles[role];
const { timeouts } = resolvedConfig;
const model = resolveModel(role, level, resolvedRole);
const worker = getWorker(project, role);
const existingSessionKey = getSessionForLevel(worker, level);
@@ -194,16 +195,22 @@ export async function dispatchTask(
channel: opts.channel ?? "telegram",
runtime,
},
).catch(() => { /* non-fatal */ });
).catch((err) => {
auditLog(workspaceDir, "dispatch_warning", {
step: "notify", issue: issueId, role,
error: (err as Error).message ?? String(err),
}).catch(() => {});
});
// Step 3: Ensure session exists (fire-and-forget — don't wait for gateway)
// Session key is deterministic, so we can proceed immediately
ensureSessionFireAndForget(sessionKey, model);
ensureSessionFireAndForget(sessionKey, model, workspaceDir, timeouts.sessionPatchMs);
// Step 4: Send task to agent (fire-and-forget)
sendToAgent(sessionKey, taskMessage, {
agentId, projectName: project.name, issueId, role,
orchestratorSessionKey: opts.sessionKey,
agentId, projectName: project.name, issueId, role, level,
orchestratorSessionKey: opts.sessionKey, workspaceDir,
dispatchTimeoutMs: timeouts.dispatchMs,
});
// Step 5: Update worker state
@@ -241,19 +248,24 @@ export async function dispatchTask(
* Session key is deterministic, so we don't need to wait for confirmation.
* If this fails, health check will catch orphaned state later.
*/
function ensureSessionFireAndForget(sessionKey: string, model: string): void {
function ensureSessionFireAndForget(sessionKey: string, model: string, workspaceDir: string, timeoutMs = 30_000): void {
runCommand(
["openclaw", "gateway", "call", "sessions.patch", "--params", JSON.stringify({ key: sessionKey, model })],
{ timeoutMs: 30_000 },
).catch(() => { /* fire-and-forget */ });
{ timeoutMs },
).catch((err) => {
auditLog(workspaceDir, "dispatch_warning", {
step: "ensureSession", sessionKey,
error: (err as Error).message ?? String(err),
}).catch(() => {});
});
}
function sendToAgent(
sessionKey: string, taskMessage: string,
opts: { agentId?: string; projectName: string; issueId: number; role: string; orchestratorSessionKey?: string },
opts: { agentId?: string; projectName: string; issueId: number; role: string; level?: string; orchestratorSessionKey?: string; workspaceDir: string; dispatchTimeoutMs?: number },
): void {
const gatewayParams = JSON.stringify({
idempotencyKey: `devclaw-${opts.projectName}-${opts.issueId}-${opts.role}-${Date.now()}`,
idempotencyKey: `devclaw-${opts.projectName}-${opts.issueId}-${opts.role}-${opts.level ?? "unknown"}-${sessionKey}`,
agentId: opts.agentId ?? "devclaw",
sessionKey,
message: taskMessage,
@@ -264,8 +276,14 @@ function sendToAgent(
// Fire-and-forget: long-running agent turn, don't await
runCommand(
["openclaw", "gateway", "call", "agent", "--params", gatewayParams, "--expect-final", "--json"],
{ timeoutMs: 600_000 },
).catch(() => { /* fire-and-forget */ });
{ timeoutMs: opts.dispatchTimeoutMs ?? 600_000 },
).catch((err) => {
auditLog(opts.workspaceDir, "dispatch_warning", {
step: "sendToAgent", sessionKey,
issue: opts.issueId, role: opts.role,
error: (err as Error).message ?? String(err),
}).catch(() => {});
});
}
async function recordWorkerState(

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;
}
/**

View File

@@ -6,8 +6,11 @@ import {
type Issue,
type StateLabel,
type IssueComment,
type PrStatus,
PrState,
} from "./provider.js";
import { runCommand } from "../run-command.js";
import { withResilience } from "./resilience.js";
import {
DEFAULT_WORKFLOW,
getStateLabels,
@@ -41,8 +44,10 @@ export class GitHubProvider implements IssueProvider {
}
private async gh(args: string[]): Promise<string> {
const result = await runCommand(["gh", ...args], { timeoutMs: 30_000, cwd: this.repoPath });
return result.stdout.trim();
return withResilience(async () => {
const result = await runCommand(["gh", ...args], { timeoutMs: 30_000, cwd: this.repoPath });
return result.stdout.trim();
});
}
async ensureLabel(name: string, color: string): Promise<void> {
@@ -125,6 +130,28 @@ export class GitHubProvider implements IssueProvider {
} catch { return null; }
}
async getPrStatus(issueId: number): Promise<PrStatus> {
const pat = `#${issueId}`;
// Check open PRs first
try {
const raw = await this.gh(["pr", "list", "--state", "open", "--json", "title,body,url,reviewDecision", "--limit", "20"]);
const prs = JSON.parse(raw) as Array<{ title: string; body: string; url: string; reviewDecision: string }>;
const pr = prs.find((p) => p.title.includes(pat) || (p.body ?? "").includes(pat));
if (pr) {
const state = pr.reviewDecision === "APPROVED" ? PrState.APPROVED : PrState.OPEN;
return { state, url: pr.url };
}
} catch { /* continue to merged check */ }
// Check merged PRs
try {
const raw = await this.gh(["pr", "list", "--state", "merged", "--json", "title,body,url", "--limit", "20"]);
const prs = JSON.parse(raw) as Array<{ title: string; body: string; url: string }>;
const pr = prs.find((p) => p.title.includes(pat) || (p.body ?? "").includes(pat));
if (pr) return { state: PrState.MERGED, url: pr.url };
} catch { /* ignore */ }
return { state: PrState.CLOSED, url: null };
}
async addComment(issueId: number, body: string): Promise<void> {
await this.gh(["issue", "comment", String(issueId), "--body", body]);
}

View File

@@ -6,8 +6,11 @@ import {
type Issue,
type StateLabel,
type IssueComment,
type PrStatus,
PrState,
} from "./provider.js";
import { runCommand } from "../run-command.js";
import { withResilience } from "./resilience.js";
import {
DEFAULT_WORKFLOW,
getStateLabels,
@@ -25,8 +28,10 @@ export class GitLabProvider implements IssueProvider {
}
private async glab(args: string[]): Promise<string> {
const result = await runCommand(["glab", ...args], { timeoutMs: 30_000, cwd: this.repoPath });
return result.stdout.trim();
return withResilience(async () => {
const result = await runCommand(["glab", ...args], { timeoutMs: 30_000, cwd: this.repoPath });
return result.stdout.trim();
});
}
async ensureLabel(name: string, color: string): Promise<void> {
@@ -122,6 +127,28 @@ export class GitLabProvider implements IssueProvider {
} catch { return null; }
}
async getPrStatus(issueId: number): Promise<PrStatus> {
const pat = `#${issueId}`;
// Check open MRs first
try {
const raw = await this.glab(["mr", "list", "--output", "json", "--state", "opened"]);
const mrs = JSON.parse(raw) as Array<{ title: string; description: string; web_url: string; approved_by?: Array<unknown> }>;
const mr = mrs.find((m) => m.title.includes(pat) || (m.description ?? "").includes(pat));
if (mr) {
const state = mr.approved_by && mr.approved_by.length > 0 ? PrState.APPROVED : PrState.OPEN;
return { state, url: mr.web_url };
}
} catch { /* continue to merged check */ }
// Check merged MRs
try {
const raw = await this.glab(["mr", "list", "--output", "json", "--state", "merged"]);
const mrs = JSON.parse(raw) as Array<{ title: string; description: string; web_url: string }>;
const mr = mrs.find((m) => m.title.includes(pat) || (m.description ?? "").includes(pat));
if (mr) return { state: PrState.MERGED, url: mr.web_url };
} catch { /* ignore */ }
return { state: PrState.CLOSED, url: null };
}
async addComment(issueId: number, body: string): Promise<void> {
// Pass message directly as argv — no shell escaping needed with spawn
await this.glab(["issue", "note", String(issueId), "--message", body]);

View File

@@ -28,6 +28,20 @@ export type IssueComment = {
created_at: string;
};
/** Built-in PR states. */
export const PrState = {
OPEN: "open",
APPROVED: "approved",
MERGED: "merged",
CLOSED: "closed",
} as const;
export type PrState = (typeof PrState)[keyof typeof PrState];
export type PrStatus = {
state: PrState;
url: string | null;
};
// ---------------------------------------------------------------------------
// Provider interface
// ---------------------------------------------------------------------------
@@ -46,6 +60,7 @@ export interface IssueProvider {
getCurrentStateLabel(issue: Issue): StateLabel | null;
hasMergedMR(issueId: number): Promise<boolean>;
getMergedMRUrl(issueId: number): Promise<string | null>;
getPrStatus(issueId: number): Promise<PrStatus>;
addComment(issueId: number, body: string): Promise<void>;
healthCheck(): Promise<boolean>;
}

View File

@@ -0,0 +1,49 @@
/**
* providers/resilience.ts — Retry and circuit breaker policies for provider calls.
*
* Uses cockatiel for lightweight resilience without heavyweight orchestration.
* Applied to GitHub/GitLab CLI calls that can fail due to network, rate limits, or timeouts.
*/
import {
ExponentialBackoff,
retry,
circuitBreaker,
ConsecutiveBreaker,
handleAll,
wrap,
type IPolicy,
} from "cockatiel";
/**
* Default retry policy: 3 attempts with exponential backoff.
* Handles all errors (network, timeout, CLI failure).
*/
const retryPolicy = retry(handleAll, {
maxAttempts: 3,
backoff: new ExponentialBackoff({
initialDelay: 500,
maxDelay: 5_000,
}),
});
/**
* Circuit breaker: opens after 5 consecutive failures, half-opens after 30s.
* Prevents hammering a provider that's down.
*/
const breakerPolicy = circuitBreaker(handleAll, {
halfOpenAfter: 30_000,
breaker: new ConsecutiveBreaker(5),
});
/**
* Combined policy: circuit breaker wrapping retry.
* If circuit is open, calls fail fast without retrying.
*/
export const providerPolicy: IPolicy = wrap(breakerPolicy, retryPolicy);
/**
* Execute a provider call with retry + circuit breaker.
*/
export function withResilience<T>(fn: () => Promise<T>): Promise<T> {
return providerPolicy.execute(() => fn());
}

View File

@@ -185,13 +185,14 @@ describe("emoji", () => {
describe("completion results", () => {
it("should return valid results per role", () => {
assert.deepStrictEqual([...getCompletionResults("developer")], ["done", "blocked"]);
assert.deepStrictEqual([...getCompletionResults("developer")], ["done", "review", "blocked"]);
assert.deepStrictEqual([...getCompletionResults("tester")], ["pass", "fail", "refine", "blocked"]);
assert.deepStrictEqual([...getCompletionResults("architect")], ["done", "blocked"]);
});
it("should validate results", () => {
assert.strictEqual(isValidResult("developer", "done"), true);
assert.strictEqual(isValidResult("developer", "review"), true);
assert.strictEqual(isValidResult("developer", "pass"), false);
assert.strictEqual(isValidResult("tester", "pass"), true);
assert.strictEqual(isValidResult("tester", "done"), false);

View File

@@ -30,7 +30,7 @@ export const ROLE_REGISTRY: Record<string, RoleConfig> = {
senior: "🧠",
},
fallbackEmoji: "🔧",
completionResults: ["done", "blocked"],
completionResults: ["done", "review", "blocked"],
sessionKeyPattern: "developer",
notifications: { onStart: true, onComplete: true },
},

View File

@@ -0,0 +1,253 @@
/**
* E2E bootstrap tests — verifies the full bootstrap hook chain:
* dispatchTask() → session key → registerBootstrapHook fires → bootstrapFiles injected
*
* Uses simulateBootstrap() which registers the real hook with a mock API,
* fires it with the session key from dispatch, and returns the resulting
* bootstrapFiles array — proving instructions actually reach the worker.
*
* Run: npx tsx --test lib/services/bootstrap.e2e.test.ts
*/
import { describe, it, afterEach } from "node:test";
import assert from "node:assert";
import { createTestHarness, type TestHarness } from "../testing/index.js";
import { dispatchTask } from "../dispatch.js";
describe("E2E bootstrap — hook injection", () => {
let h: TestHarness;
afterEach(async () => {
if (h) await h.cleanup();
});
it("should inject project-specific instructions into bootstrapFiles", async () => {
h = await createTestHarness({ projectName: "my-app" });
h.provider.seedIssue({ iid: 1, title: "Add feature", labels: ["To Do"] });
// Write both default and project-specific prompts
await h.writePrompt("developer", "# Default Developer\nGeneric instructions.");
await h.writePrompt("developer", "# My App Developer\nUse React. Follow our design system.", "my-app");
// Dispatch to get the session key
const result = await dispatchTask({
workspaceDir: h.workspaceDir,
agentId: "main",
groupId: h.groupId,
project: h.project,
issueId: 1,
issueTitle: "Add feature",
issueDescription: "",
issueUrl: "https://example.com/issues/1",
role: "developer",
level: "medior",
fromLabel: "To Do",
toLabel: "Doing",
transitionLabel: (id, from, to) => h.provider.transitionLabel(id, from, to),
provider: h.provider,
});
// Fire the actual bootstrap hook with the dispatch session key
const files = await h.simulateBootstrap(result.sessionKey);
// Should have exactly one injected file
assert.strictEqual(files.length, 1, `Expected 1 bootstrap file, got ${files.length}`);
assert.strictEqual(files[0].name, "WORKER_INSTRUCTIONS.md");
assert.strictEqual(files[0].missing, false);
assert.ok(files[0].path.includes("my-app"), `Path should reference project: ${files[0].path}`);
assert.ok(files[0].path.includes("developer"), `Path should reference role: ${files[0].path}`);
// Content should be project-specific, NOT default
const content = files[0].content!;
assert.ok(content.includes("My App Developer"), `Got: ${content}`);
assert.ok(content.includes("Use React"));
assert.ok(!content.includes("Generic instructions"));
});
it("should fall back to default instructions when no project override exists", async () => {
h = await createTestHarness({ projectName: "other-app" });
h.provider.seedIssue({ iid: 2, title: "Fix bug", labels: ["To Do"] });
// Only write default prompt — no project-specific
await h.writePrompt("developer", "# Default Developer\nFollow coding standards.");
const result = await dispatchTask({
workspaceDir: h.workspaceDir,
agentId: "main",
groupId: h.groupId,
project: h.project,
issueId: 2,
issueTitle: "Fix bug",
issueDescription: "",
issueUrl: "https://example.com/issues/2",
role: "developer",
level: "junior",
fromLabel: "To Do",
toLabel: "Doing",
transitionLabel: (id, from, to) => h.provider.transitionLabel(id, from, to),
provider: h.provider,
});
const files = await h.simulateBootstrap(result.sessionKey);
assert.strictEqual(files.length, 1);
assert.ok(files[0].content!.includes("Default Developer"));
assert.ok(files[0].content!.includes("Follow coding standards"));
});
it("should inject scaffolded default instructions when no overrides exist", async () => {
h = await createTestHarness({ projectName: "bare-app" });
h.provider.seedIssue({ iid: 3, title: "Chore", labels: ["To Do"] });
// Don't write any custom prompts — ensureWorkspaceMigrated scaffolds defaults
const result = await dispatchTask({
workspaceDir: h.workspaceDir,
agentId: "main",
groupId: h.groupId,
project: h.project,
issueId: 3,
issueTitle: "Chore",
issueDescription: "",
issueUrl: "https://example.com/issues/3",
role: "developer",
level: "medior",
fromLabel: "To Do",
toLabel: "Doing",
transitionLabel: (id, from, to) => h.provider.transitionLabel(id, from, to),
provider: h.provider,
});
const files = await h.simulateBootstrap(result.sessionKey);
// Default developer instructions are scaffolded by ensureDefaultFiles
assert.strictEqual(files.length, 1);
assert.ok(files[0].content!.includes("DEVELOPER"), "Should contain DEVELOPER heading");
assert.ok(files[0].content!.includes("work_finish"), "Should reference work_finish");
});
it("should NOT inject anything for unknown custom roles", async () => {
h = await createTestHarness({ projectName: "custom-app" });
// Simulate a session key for a custom role that has no prompt file
// This key won't parse because "reviewer" isn't in the role registry
const files = await h.simulateBootstrap(
"agent:main:subagent:custom-app-reviewer-medior",
);
assert.strictEqual(files.length, 0, "Should not inject files for unknown roles");
});
it("should resolve tester instructions independently from developer", async () => {
h = await createTestHarness({ projectName: "multi-role" });
h.provider.seedIssue({ iid: 4, title: "Test thing", labels: ["To Test"] });
// Write project-specific for developer, default for tester
await h.writePrompt("developer", "# Dev for multi-role\nSpecific dev rules.", "multi-role");
await h.writePrompt("tester", "# Default Tester\nRun integration tests.");
// Dispatch as tester
const result = await dispatchTask({
workspaceDir: h.workspaceDir,
agentId: "main",
groupId: h.groupId,
project: h.project,
issueId: 4,
issueTitle: "Test thing",
issueDescription: "",
issueUrl: "https://example.com/issues/4",
role: "tester",
level: "medior",
fromLabel: "To Test",
toLabel: "Testing",
transitionLabel: (id, from, to) => h.provider.transitionLabel(id, from, to),
provider: h.provider,
});
// Simulate bootstrap for the tester session
const testerFiles = await h.simulateBootstrap(result.sessionKey);
assert.strictEqual(testerFiles.length, 1);
assert.ok(testerFiles[0].content!.includes("Default Tester"));
assert.ok(!testerFiles[0].content!.includes("Dev for multi-role"));
// Simulate bootstrap for a developer session on the same project
const devKey = result.sessionKey.replace("-tester-", "-developer-");
const devFiles = await h.simulateBootstrap(devKey);
assert.strictEqual(devFiles.length, 1);
assert.ok(devFiles[0].content!.includes("Dev for multi-role"));
assert.ok(devFiles[0].content!.includes("Specific dev rules"));
});
it("should handle project names with hyphens correctly", async () => {
h = await createTestHarness({ projectName: "my-cool-project" });
h.provider.seedIssue({ iid: 5, title: "Hyphen test", labels: ["To Do"] });
await h.writePrompt(
"developer",
"# Hyphenated Project\nThis project has hyphens in the name.",
"my-cool-project",
);
const result = await dispatchTask({
workspaceDir: h.workspaceDir,
agentId: "main",
groupId: h.groupId,
project: h.project,
issueId: 5,
issueTitle: "Hyphen test",
issueDescription: "",
issueUrl: "https://example.com/issues/5",
role: "developer",
level: "senior",
fromLabel: "To Do",
toLabel: "Doing",
transitionLabel: (id, from, to) => h.provider.transitionLabel(id, from, to),
provider: h.provider,
});
const files = await h.simulateBootstrap(result.sessionKey);
assert.strictEqual(files.length, 1);
assert.ok(files[0].content!.includes("Hyphenated Project"));
assert.ok(files[0].path.includes("my-cool-project"));
});
it("should resolve architect instructions with project override", async () => {
h = await createTestHarness({ projectName: "arch-proj" });
h.provider.seedIssue({ iid: 6, title: "Design API", labels: ["To Design"] });
await h.writePrompt("architect", "# Default Architect\nGeneral design guidelines.");
await h.writePrompt("architect", "# Arch Proj Architect\nUse event-driven architecture.", "arch-proj");
const result = await dispatchTask({
workspaceDir: h.workspaceDir,
agentId: "main",
groupId: h.groupId,
project: h.project,
issueId: 6,
issueTitle: "Design API",
issueDescription: "",
issueUrl: "https://example.com/issues/6",
role: "architect",
level: "senior",
fromLabel: "To Design",
toLabel: "Designing",
transitionLabel: (id, from, to) => h.provider.transitionLabel(id, from, to),
provider: h.provider,
});
const files = await h.simulateBootstrap(result.sessionKey);
assert.strictEqual(files.length, 1);
assert.ok(files[0].content!.includes("Arch Proj Architect"));
assert.ok(files[0].content!.includes("event-driven"));
assert.ok(!files[0].content!.includes("General design guidelines"));
});
it("should not inject when session key is not a DevClaw subagent", async () => {
h = await createTestHarness();
// Non-DevClaw session key — hook should no-op
const files = await h.simulateBootstrap("agent:main:orchestrator");
assert.strictEqual(files.length, 0);
});
});

View File

@@ -83,13 +83,13 @@ export type SessionLookup = Map<string, GatewaySession>;
* Returns null if gateway is unavailable (timeout, error, etc).
* Callers should skip session liveness checks if null — unknown ≠ dead.
*/
export async function fetchGatewaySessions(): Promise<SessionLookup | null> {
export async function fetchGatewaySessions(gatewayTimeoutMs = 15_000): Promise<SessionLookup | null> {
const lookup: SessionLookup = new Map();
try {
const result = await runCommand(
["openclaw", "gateway", "call", "status", "--json"],
{ timeoutMs: 15_000 },
{ timeoutMs: gatewayTimeoutMs },
);
const jsonStart = result.stdout.indexOf("{");
@@ -151,10 +151,13 @@ export async function checkWorkerHealth(opts: {
sessions: SessionLookup | null;
/** Workflow config (defaults to DEFAULT_WORKFLOW) */
workflow?: WorkflowConfig;
/** Hours after which an active worker is considered stale (default: 2) */
staleWorkerHours?: number;
}): Promise<HealthFix[]> {
const {
workspaceDir, groupId, project, role, autoFix, provider, sessions,
workflow = DEFAULT_WORKFLOW,
staleWorkerHours = 2,
} = opts;
const fixes: HealthFix[] = [];
@@ -316,7 +319,7 @@ export async function checkWorkerHealth(opts: {
// ---------------------------------------------------------------------------
if (worker.active && worker.startTime && sessionKey && sessions && isSessionAlive(sessionKey, sessions)) {
const hours = (Date.now() - new Date(worker.startTime).getTime()) / 3_600_000;
if (hours > 2) {
if (hours > staleWorkerHours) {
const fix: HealthFix = {
issue: {
type: "stale_worker",

View File

@@ -18,7 +18,10 @@ import { log as auditLog } from "../audit.js";
import { DATA_DIR } from "../setup/migrate-layout.js";
import { checkWorkerHealth, scanOrphanedLabels, fetchGatewaySessions, type SessionLookup } from "./health.js";
import { projectTick } from "./tick.js";
import { reviewPass } from "./review.js";
import { createProvider } from "../providers/index.js";
import { loadConfig } from "../config/index.js";
import { ExecutionMode } from "../workflow.js";
// ---------------------------------------------------------------------------
// Types
@@ -39,6 +42,7 @@ type TickResult = {
totalPickups: number;
totalHealthFixes: number;
totalSkipped: number;
totalReviewTransitions: number;
};
type ServiceContext = {
@@ -191,6 +195,7 @@ async function processAllAgents(
totalPickups: 0,
totalHealthFixes: 0,
totalSkipped: 0,
totalReviewTransitions: 0,
};
// Fetch gateway sessions once for all agents/projects
@@ -209,6 +214,7 @@ async function processAllAgents(
result.totalPickups += agentResult.totalPickups;
result.totalHealthFixes += agentResult.totalHealthFixes;
result.totalSkipped += agentResult.totalSkipped;
result.totalReviewTransitions += agentResult.totalReviewTransitions;
}
return result;
@@ -218,9 +224,9 @@ async function processAllAgents(
* Log tick results if anything happened.
*/
function logTickResult(result: TickResult, logger: ServiceContext["logger"]): void {
if (result.totalPickups > 0 || result.totalHealthFixes > 0) {
if (result.totalPickups > 0 || result.totalHealthFixes > 0 || result.totalReviewTransitions > 0) {
logger.info(
`work_heartbeat tick: ${result.totalPickups} pickups, ${result.totalHealthFixes} health fixes, ${result.totalSkipped} skipped`,
`work_heartbeat tick: ${result.totalPickups} pickups, ${result.totalHealthFixes} health fixes, ${result.totalReviewTransitions} review transitions, ${result.totalSkipped} skipped`,
);
}
}
@@ -243,60 +249,83 @@ export async function tick(opts: {
const projectIds = Object.keys(data.projects);
if (projectIds.length === 0) {
return { totalPickups: 0, totalHealthFixes: 0, totalSkipped: 0 };
return { totalPickups: 0, totalHealthFixes: 0, totalSkipped: 0, totalReviewTransitions: 0 };
}
const result: TickResult = {
totalPickups: 0,
totalHealthFixes: 0,
totalSkipped: 0,
totalReviewTransitions: 0,
};
const projectExecution = (pluginConfig?.projectExecution as string) ?? "parallel";
const projectExecution = (pluginConfig?.projectExecution as string) ?? ExecutionMode.PARALLEL;
let activeProjects = 0;
for (const groupId of projectIds) {
const project = data.projects[groupId];
if (!project) continue;
try {
const project = data.projects[groupId];
if (!project) continue;
// Health pass: auto-fix zombies and stale workers
result.totalHealthFixes += await performHealthPass(
workspaceDir,
groupId,
project,
sessions,
);
const { provider } = await createProvider({ repo: project.repo, provider: project.provider });
const resolvedConfig = await loadConfig(workspaceDir, project.name);
// Budget check: stop if we've hit the limit
const remaining = config.maxPickupsPerTick - result.totalPickups;
if (remaining <= 0) break;
// Health pass: auto-fix zombies and stale workers
result.totalHealthFixes += await performHealthPass(
workspaceDir,
groupId,
project,
sessions,
provider,
resolvedConfig.timeouts.staleWorkerHours,
);
// Sequential project guard: don't start new projects if one is active
const isProjectActive = await checkProjectActive(workspaceDir, groupId);
if (projectExecution === "sequential" && !isProjectActive && activeProjects >= 1) {
// Review pass: transition issues whose PR check condition is met
result.totalReviewTransitions += await reviewPass({
workspaceDir,
groupId,
workflow: resolvedConfig.workflow,
provider,
repoPath: project.repo,
gitPullTimeoutMs: resolvedConfig.timeouts.gitPullMs,
});
// Budget check: stop if we've hit the limit
const remaining = config.maxPickupsPerTick - result.totalPickups;
if (remaining <= 0) break;
// Sequential project guard: don't start new projects if one is active
const isProjectActive = await checkProjectActive(workspaceDir, groupId);
if (projectExecution === ExecutionMode.SEQUENTIAL && !isProjectActive && activeProjects >= 1) {
result.totalSkipped++;
continue;
}
// Tick pass: fill free worker slots
const tickResult = await projectTick({
workspaceDir,
groupId,
agentId,
pluginConfig,
maxPickups: remaining,
});
result.totalPickups += tickResult.pickups.length;
result.totalSkipped += tickResult.skipped.length;
// Notifications now handled by dispatchTask
if (isProjectActive || tickResult.pickups.length > 0) activeProjects++;
} catch (err) {
// Per-project isolation: one failing project doesn't crash the entire tick
opts.logger.warn(`Heartbeat tick failed for project ${groupId}: ${(err as Error).message}`);
result.totalSkipped++;
continue;
}
// Tick pass: fill free worker slots
const tickResult = await projectTick({
workspaceDir,
groupId,
agentId,
pluginConfig,
maxPickups: remaining,
});
result.totalPickups += tickResult.pickups.length;
result.totalSkipped += tickResult.skipped.length;
// Notifications now handled by dispatchTask
if (isProjectActive || tickResult.pickups.length > 0) activeProjects++;
}
await auditLog(workspaceDir, "heartbeat_tick", {
projectsScanned: projectIds.length,
healthFixes: result.totalHealthFixes,
reviewTransitions: result.totalReviewTransitions,
pickups: result.totalPickups,
skipped: result.totalSkipped,
});
@@ -312,8 +341,9 @@ async function performHealthPass(
groupId: string,
project: any,
sessions: SessionLookup | null,
provider: import("../providers/provider.js").IssueProvider,
staleWorkerHours?: number,
): Promise<number> {
const { provider } = await createProvider({ repo: project.repo, provider: project.provider });
let fixedCount = 0;
for (const role of Object.keys(project.workers)) {
@@ -326,6 +356,7 @@ async function performHealthPass(
sessions,
autoFix: true,
provider,
staleWorkerHours,
});
fixedCount += healthFixes.filter((f) => f.fixed).length;

View File

@@ -0,0 +1,747 @@
/**
* E2E pipeline tests — exercises the full workflow lifecycle.
*
* Tests dispatch → completion → review pass using:
* - TestProvider (in-memory issues, call tracking)
* - Mock runCommand (captures gateway calls, task messages)
* - Real projects.json on disk (temp workspace)
*
* Run: npx tsx --test lib/services/pipeline.e2e.test.ts
*/
import { describe, it, beforeEach, afterEach } from "node:test";
import assert from "node:assert";
import { createTestHarness, type TestHarness } from "../testing/index.js";
import { dispatchTask } from "../dispatch.js";
import { executeCompletion } from "./pipeline.js";
import { reviewPass } from "./review.js";
import { DEFAULT_WORKFLOW } from "../workflow.js";
import { readProjects, getWorker } from "../projects.js";
// ---------------------------------------------------------------------------
// Test suite
// ---------------------------------------------------------------------------
describe("E2E pipeline", () => {
let h: TestHarness;
afterEach(async () => {
if (h) await h.cleanup();
});
// =========================================================================
// Dispatch
// =========================================================================
describe("dispatchTask", () => {
beforeEach(async () => {
h = await createTestHarness();
// Seed a "To Do" issue
h.provider.seedIssue({ iid: 42, title: "Add login page", labels: ["To Do"] });
});
it("should transition label, update worker state, and fire gateway calls", async () => {
const result = await dispatchTask({
workspaceDir: h.workspaceDir,
agentId: "test-agent",
groupId: h.groupId,
project: h.project,
issueId: 42,
issueTitle: "Add login page",
issueDescription: "Build the login page",
issueUrl: "https://example.com/issues/42",
role: "developer",
level: "medior",
fromLabel: "To Do",
toLabel: "Doing",
transitionLabel: (id, from, to) => h.provider.transitionLabel(id, from, to),
provider: h.provider,
});
// Verify dispatch result
assert.strictEqual(result.sessionAction, "spawn");
assert.ok(result.sessionKey.includes("test-project-developer-medior"));
assert.ok(result.announcement.includes("#42"));
assert.ok(result.announcement.includes("Add login page"));
// Verify label transitioned on the issue
const issue = await h.provider.getIssue(42);
assert.ok(issue.labels.includes("Doing"), `Expected "Doing" label, got: ${issue.labels}`);
assert.ok(!issue.labels.includes("To Do"), "Should not have 'To Do' label");
// Verify worker state updated in projects.json
const data = await readProjects(h.workspaceDir);
const worker = getWorker(data.projects[h.groupId], "developer");
assert.strictEqual(worker.active, true);
assert.strictEqual(worker.issueId, "42");
assert.strictEqual(worker.level, "medior");
// Verify gateway commands were fired
assert.ok(h.commands.sessionPatches().length > 0, "Should have patched session");
assert.ok(h.commands.taskMessages().length > 0, "Should have sent task message");
// Verify task message contains issue context
const taskMsg = h.commands.taskMessages()[0];
assert.ok(taskMsg.includes("Add login page"), "Task message should include title");
assert.ok(taskMsg.includes(h.groupId), "Task message should include groupId");
assert.ok(taskMsg.includes("work_finish"), "Task message should reference work_finish");
});
it("should include comments in task message", async () => {
h.provider.comments.set(42, [
{ author: "alice", body: "Please use OAuth", created_at: "2026-01-01T00:00:00Z" },
{ author: "bob", body: "Agreed, OAuth2 flow", created_at: "2026-01-02T00:00:00Z" },
]);
await dispatchTask({
workspaceDir: h.workspaceDir,
groupId: h.groupId,
project: h.project,
issueId: 42,
issueTitle: "Add login page",
issueDescription: "",
issueUrl: "https://example.com/issues/42",
role: "developer",
level: "medior",
fromLabel: "To Do",
toLabel: "Doing",
transitionLabel: (id, from, to) => h.provider.transitionLabel(id, from, to),
provider: h.provider,
});
const taskMsg = h.commands.taskMessages()[0];
assert.ok(taskMsg.includes("alice"), "Should include comment author");
assert.ok(taskMsg.includes("Please use OAuth"), "Should include comment body");
assert.ok(taskMsg.includes("bob"), "Should include second comment author");
});
it("should reuse existing session when available", async () => {
// Set up worker with existing session
h = await createTestHarness({
workers: {
developer: {
sessions: { medior: "agent:test-agent:subagent:test-project-developer-medior" },
},
},
});
h.provider.seedIssue({ iid: 42, title: "Quick fix", labels: ["To Do"] });
const result = await dispatchTask({
workspaceDir: h.workspaceDir,
agentId: "test-agent",
groupId: h.groupId,
project: h.project,
issueId: 42,
issueTitle: "Quick fix",
issueDescription: "",
issueUrl: "https://example.com/issues/42",
role: "developer",
level: "medior",
fromLabel: "To Do",
toLabel: "Doing",
transitionLabel: (id, from, to) => h.provider.transitionLabel(id, from, to),
provider: h.provider,
});
assert.strictEqual(result.sessionAction, "send");
});
});
// =========================================================================
// Completion — developer:done
// =========================================================================
describe("executeCompletion — developer:done", () => {
beforeEach(async () => {
h = await createTestHarness({
workers: {
developer: { active: true, issueId: "10", level: "medior" },
},
});
h.provider.seedIssue({ iid: 10, title: "Build feature X", labels: ["Doing"] });
});
it("should transition Doing → To Test, deactivate worker, run gitPull+detectPr actions", async () => {
h.provider.mergedMrUrls.set(10, "https://example.com/mr/5");
const output = await executeCompletion({
workspaceDir: h.workspaceDir,
groupId: h.groupId,
role: "developer",
result: "done",
issueId: 10,
summary: "Built feature X",
provider: h.provider,
repoPath: "/tmp/test-repo",
projectName: "test-project",
});
// Label transition
assert.strictEqual(output.labelTransition, "Doing → To Test");
assert.ok(output.announcement.includes("#10"));
// Issue state
const issue = await h.provider.getIssue(10);
assert.ok(issue.labels.includes("To Test"), `Labels: ${issue.labels}`);
assert.ok(!issue.labels.includes("Doing"));
// Worker deactivated
const data = await readProjects(h.workspaceDir);
const worker = getWorker(data.projects[h.groupId], "developer");
assert.strictEqual(worker.active, false);
// PR URL detected
assert.strictEqual(output.prUrl, "https://example.com/mr/5");
// gitPull action was executed
const gitCmds = h.commands.commands.filter((c) => c.argv[0] === "git");
assert.ok(gitCmds.length > 0, "Should have run git pull");
assert.deepStrictEqual(gitCmds[0].argv, ["git", "pull"]);
// Issue NOT closed (done goes to To Test, not Done)
assert.strictEqual(output.issueClosed, false);
});
});
// =========================================================================
// Completion — developer:review
// =========================================================================
describe("executeCompletion — developer:review", () => {
beforeEach(async () => {
h = await createTestHarness({
workers: {
developer: { active: true, issueId: "20", level: "senior" },
},
});
h.provider.seedIssue({ iid: 20, title: "Refactor auth", labels: ["Doing"] });
});
it("should transition Doing → In Review, deactivate worker", async () => {
const output = await executeCompletion({
workspaceDir: h.workspaceDir,
groupId: h.groupId,
role: "developer",
result: "review",
issueId: 20,
summary: "PR open for review",
prUrl: "https://example.com/pr/3",
provider: h.provider,
repoPath: "/tmp/test-repo",
projectName: "test-project",
});
assert.strictEqual(output.labelTransition, "Doing → In Review");
assert.ok(output.nextState.includes("review"), `nextState: ${output.nextState}`);
const issue = await h.provider.getIssue(20);
assert.ok(issue.labels.includes("In Review"), `Labels: ${issue.labels}`);
// Worker should be deactivated
const data = await readProjects(h.workspaceDir);
assert.strictEqual(getWorker(data.projects[h.groupId], "developer").active, false);
// Issue should NOT be closed
assert.strictEqual(output.issueClosed, false);
});
});
// =========================================================================
// Completion — tester:pass
// =========================================================================
describe("executeCompletion — tester:pass", () => {
beforeEach(async () => {
h = await createTestHarness({
workers: {
tester: { active: true, issueId: "30", level: "medior" },
},
});
h.provider.seedIssue({ iid: 30, title: "Verify login", labels: ["Testing"] });
});
it("should transition Testing → Done, close issue", async () => {
const output = await executeCompletion({
workspaceDir: h.workspaceDir,
groupId: h.groupId,
role: "tester",
result: "pass",
issueId: 30,
summary: "All tests pass",
provider: h.provider,
repoPath: "/tmp/test-repo",
projectName: "test-project",
});
assert.strictEqual(output.labelTransition, "Testing → Done");
assert.strictEqual(output.issueClosed, true);
const issue = await h.provider.getIssue(30);
assert.ok(issue.labels.includes("Done"));
assert.strictEqual(issue.state, "closed");
// Verify closeIssue was called
const closeCalls = h.provider.callsTo("closeIssue");
assert.strictEqual(closeCalls.length, 1);
assert.strictEqual(closeCalls[0].args.issueId, 30);
});
});
// =========================================================================
// Completion — tester:fail
// =========================================================================
describe("executeCompletion — tester:fail", () => {
beforeEach(async () => {
h = await createTestHarness({
workers: {
tester: { active: true, issueId: "40", level: "medior" },
},
});
h.provider.seedIssue({ iid: 40, title: "Check signup", labels: ["Testing"] });
});
it("should transition Testing → To Improve, reopen issue", async () => {
const output = await executeCompletion({
workspaceDir: h.workspaceDir,
groupId: h.groupId,
role: "tester",
result: "fail",
issueId: 40,
summary: "Signup form validation broken",
provider: h.provider,
repoPath: "/tmp/test-repo",
projectName: "test-project",
});
assert.strictEqual(output.labelTransition, "Testing → To Improve");
assert.strictEqual(output.issueReopened, true);
const issue = await h.provider.getIssue(40);
assert.ok(issue.labels.includes("To Improve"));
assert.strictEqual(issue.state, "opened");
const reopenCalls = h.provider.callsTo("reopenIssue");
assert.strictEqual(reopenCalls.length, 1);
});
});
// =========================================================================
// Completion — developer:blocked
// =========================================================================
describe("executeCompletion — developer:blocked", () => {
beforeEach(async () => {
h = await createTestHarness({
workers: {
developer: { active: true, issueId: "50", level: "junior" },
},
});
h.provider.seedIssue({ iid: 50, title: "Fix CSS", labels: ["Doing"] });
});
it("should transition Doing → Refining, no close/reopen", async () => {
const output = await executeCompletion({
workspaceDir: h.workspaceDir,
groupId: h.groupId,
role: "developer",
result: "blocked",
issueId: 50,
summary: "Need design decision",
provider: h.provider,
repoPath: "/tmp/test-repo",
projectName: "test-project",
});
assert.strictEqual(output.labelTransition, "Doing → Refining");
assert.strictEqual(output.issueClosed, false);
assert.strictEqual(output.issueReopened, false);
const issue = await h.provider.getIssue(50);
assert.ok(issue.labels.includes("Refining"));
});
});
// =========================================================================
// Review pass
// =========================================================================
describe("reviewPass", () => {
beforeEach(async () => {
h = await createTestHarness();
});
it("should transition In Review → To Test when PR is merged", async () => {
// Seed issue in "In Review" state
h.provider.seedIssue({ iid: 60, title: "Feature Y", labels: ["In Review"] });
h.provider.setPrStatus(60, { state: "merged", url: "https://example.com/pr/10" });
const transitions = await reviewPass({
workspaceDir: h.workspaceDir,
groupId: h.groupId,
workflow: DEFAULT_WORKFLOW,
provider: h.provider,
repoPath: "/tmp/test-repo",
});
assert.strictEqual(transitions, 1);
// Issue should now have "To Test" label
const issue = await h.provider.getIssue(60);
assert.ok(issue.labels.includes("To Test"), `Labels: ${issue.labels}`);
assert.ok(!issue.labels.includes("In Review"), "Should not have In Review");
// gitPull action should have been attempted
const gitCmds = h.commands.commands.filter((c) => c.argv[0] === "git");
assert.ok(gitCmds.length > 0, "Should have run git pull");
});
it("should NOT transition when PR is still open", async () => {
h.provider.seedIssue({ iid: 61, title: "Feature Z", labels: ["In Review"] });
h.provider.setPrStatus(61, { state: "open", url: "https://example.com/pr/11" });
const transitions = await reviewPass({
workspaceDir: h.workspaceDir,
groupId: h.groupId,
workflow: DEFAULT_WORKFLOW,
provider: h.provider,
repoPath: "/tmp/test-repo",
});
assert.strictEqual(transitions, 0);
// Issue should still have "In Review"
const issue = await h.provider.getIssue(61);
assert.ok(issue.labels.includes("In Review"));
});
it("should handle multiple review issues in one pass", async () => {
h.provider.seedIssue({ iid: 70, title: "PR A", labels: ["In Review"] });
h.provider.seedIssue({ iid: 71, title: "PR B", labels: ["In Review"] });
h.provider.setPrStatus(70, { state: "merged", url: "https://example.com/pr/20" });
h.provider.setPrStatus(71, { state: "merged", url: "https://example.com/pr/21" });
const transitions = await reviewPass({
workspaceDir: h.workspaceDir,
groupId: h.groupId,
workflow: DEFAULT_WORKFLOW,
provider: h.provider,
repoPath: "/tmp/test-repo",
});
assert.strictEqual(transitions, 2);
const issue70 = await h.provider.getIssue(70);
const issue71 = await h.provider.getIssue(71);
assert.ok(issue70.labels.includes("To Test"));
assert.ok(issue71.labels.includes("To Test"));
});
});
// =========================================================================
// Full lifecycle: dispatch → complete → review → test → done
// =========================================================================
describe("full lifecycle", () => {
it("developer:done → tester:pass (direct path)", async () => {
h = await createTestHarness();
// 1. Seed issue in To Do
h.provider.seedIssue({ iid: 100, title: "Build dashboard", labels: ["To Do"] });
// 2. Dispatch developer
await dispatchTask({
workspaceDir: h.workspaceDir,
agentId: "main",
groupId: h.groupId,
project: h.project,
issueId: 100,
issueTitle: "Build dashboard",
issueDescription: "Create the main dashboard view",
issueUrl: "https://example.com/issues/100",
role: "developer",
level: "medior",
fromLabel: "To Do",
toLabel: "Doing",
transitionLabel: (id, from, to) => h.provider.transitionLabel(id, from, to),
provider: h.provider,
});
let issue = await h.provider.getIssue(100);
assert.ok(issue.labels.includes("Doing"));
// 3. Developer completes → To Test
await executeCompletion({
workspaceDir: h.workspaceDir,
groupId: h.groupId,
role: "developer",
result: "done",
issueId: 100,
summary: "Dashboard built",
provider: h.provider,
repoPath: "/tmp/test-repo",
projectName: "test-project",
});
issue = await h.provider.getIssue(100);
assert.ok(issue.labels.includes("To Test"), `After dev done: ${issue.labels}`);
// 4. Simulate tester dispatch (activate worker manually for completion)
const { activateWorker } = await import("../projects.js");
await activateWorker(h.workspaceDir, h.groupId, "tester", {
issueId: "100", level: "medior",
});
await h.provider.transitionLabel(100, "To Test", "Testing");
// 5. Tester passes → Done
await executeCompletion({
workspaceDir: h.workspaceDir,
groupId: h.groupId,
role: "tester",
result: "pass",
issueId: 100,
summary: "All checks passed",
provider: h.provider,
repoPath: "/tmp/test-repo",
projectName: "test-project",
});
issue = await h.provider.getIssue(100);
assert.ok(issue.labels.includes("Done"), `Final state: ${issue.labels}`);
assert.strictEqual(issue.state, "closed");
});
it("developer:review → review pass → tester:pass (review path)", async () => {
h = await createTestHarness();
// 1. Seed issue in To Do
h.provider.seedIssue({ iid: 200, title: "Auth refactor", labels: ["To Do"] });
// 2. Dispatch developer
await dispatchTask({
workspaceDir: h.workspaceDir,
agentId: "main",
groupId: h.groupId,
project: h.project,
issueId: 200,
issueTitle: "Auth refactor",
issueDescription: "Refactor authentication system",
issueUrl: "https://example.com/issues/200",
role: "developer",
level: "senior",
fromLabel: "To Do",
toLabel: "Doing",
transitionLabel: (id, from, to) => h.provider.transitionLabel(id, from, to),
provider: h.provider,
});
// 3. Developer finishes with "review" → In Review
await executeCompletion({
workspaceDir: h.workspaceDir,
groupId: h.groupId,
role: "developer",
result: "review",
issueId: 200,
summary: "PR ready for review",
prUrl: "https://example.com/pr/50",
provider: h.provider,
repoPath: "/tmp/test-repo",
projectName: "test-project",
});
let issue = await h.provider.getIssue(200);
assert.ok(issue.labels.includes("In Review"), `After review: ${issue.labels}`);
// 4. PR gets merged — review pass picks it up
h.provider.setPrStatus(200, { state: "merged", url: "https://example.com/pr/50" });
const transitions = await reviewPass({
workspaceDir: h.workspaceDir,
groupId: h.groupId,
workflow: DEFAULT_WORKFLOW,
provider: h.provider,
repoPath: "/tmp/test-repo",
});
assert.strictEqual(transitions, 1);
issue = await h.provider.getIssue(200);
assert.ok(issue.labels.includes("To Test"), `After review pass: ${issue.labels}`);
// 5. Tester passes → Done
const { activateWorker } = await import("../projects.js");
await activateWorker(h.workspaceDir, h.groupId, "tester", {
issueId: "200", level: "medior",
});
await h.provider.transitionLabel(200, "To Test", "Testing");
await executeCompletion({
workspaceDir: h.workspaceDir,
groupId: h.groupId,
role: "tester",
result: "pass",
issueId: 200,
summary: "Auth refactor verified",
provider: h.provider,
repoPath: "/tmp/test-repo",
projectName: "test-project",
});
issue = await h.provider.getIssue(200);
assert.ok(issue.labels.includes("Done"), `Final state: ${issue.labels}`);
assert.strictEqual(issue.state, "closed");
});
it("developer:done → tester:fail → developer:done → tester:pass (fail cycle)", async () => {
h = await createTestHarness();
h.provider.seedIssue({ iid: 300, title: "Payment flow", labels: ["To Do"] });
// 1. Dispatch developer
await dispatchTask({
workspaceDir: h.workspaceDir,
agentId: "main",
groupId: h.groupId,
project: h.project,
issueId: 300,
issueTitle: "Payment flow",
issueDescription: "Implement payment",
issueUrl: "https://example.com/issues/300",
role: "developer",
level: "medior",
fromLabel: "To Do",
toLabel: "Doing",
transitionLabel: (id, from, to) => h.provider.transitionLabel(id, from, to),
provider: h.provider,
});
// 2. Developer done → To Test
await executeCompletion({
workspaceDir: h.workspaceDir,
groupId: h.groupId,
role: "developer",
result: "done",
issueId: 300,
provider: h.provider,
repoPath: "/tmp/test-repo",
projectName: "test-project",
});
// 3. Activate tester + transition
const { activateWorker } = await import("../projects.js");
await activateWorker(h.workspaceDir, h.groupId, "tester", {
issueId: "300", level: "medior",
});
await h.provider.transitionLabel(300, "To Test", "Testing");
// 4. Tester FAILS → To Improve
await executeCompletion({
workspaceDir: h.workspaceDir,
groupId: h.groupId,
role: "tester",
result: "fail",
issueId: 300,
summary: "Validation broken",
provider: h.provider,
repoPath: "/tmp/test-repo",
projectName: "test-project",
});
let issue = await h.provider.getIssue(300);
assert.ok(issue.labels.includes("To Improve"), `After fail: ${issue.labels}`);
assert.strictEqual(issue.state, "opened"); // reopened
// 5. Developer picks up again (To Improve → Doing)
await dispatchTask({
workspaceDir: h.workspaceDir,
agentId: "main",
groupId: h.groupId,
project: (await readProjects(h.workspaceDir)).projects[h.groupId],
issueId: 300,
issueTitle: "Payment flow",
issueDescription: "Implement payment",
issueUrl: "https://example.com/issues/300",
role: "developer",
level: "medior",
fromLabel: "To Improve",
toLabel: "Doing",
transitionLabel: (id, from, to) => h.provider.transitionLabel(id, from, to),
provider: h.provider,
});
// 6. Developer fixes it → To Test
await executeCompletion({
workspaceDir: h.workspaceDir,
groupId: h.groupId,
role: "developer",
result: "done",
issueId: 300,
summary: "Fixed validation",
provider: h.provider,
repoPath: "/tmp/test-repo",
projectName: "test-project",
});
issue = await h.provider.getIssue(300);
assert.ok(issue.labels.includes("To Test"), `After fix: ${issue.labels}`);
// 7. Tester passes → Done
await activateWorker(h.workspaceDir, h.groupId, "tester", {
issueId: "300", level: "medior",
});
await h.provider.transitionLabel(300, "To Test", "Testing");
await executeCompletion({
workspaceDir: h.workspaceDir,
groupId: h.groupId,
role: "tester",
result: "pass",
issueId: 300,
summary: "All good now",
provider: h.provider,
repoPath: "/tmp/test-repo",
projectName: "test-project",
});
issue = await h.provider.getIssue(300);
assert.ok(issue.labels.includes("Done"), `Final state: ${issue.labels}`);
assert.strictEqual(issue.state, "closed");
});
});
// =========================================================================
// Provider call tracking
// =========================================================================
describe("provider call tracking", () => {
it("should track all provider interactions during completion", async () => {
h = await createTestHarness({
workers: {
tester: { active: true, issueId: "90", level: "medior" },
},
});
h.provider.seedIssue({ iid: 90, title: "Test tracking", labels: ["Testing"] });
h.provider.resetCalls();
await executeCompletion({
workspaceDir: h.workspaceDir,
groupId: h.groupId,
role: "tester",
result: "pass",
issueId: 90,
provider: h.provider,
repoPath: "/tmp/test-repo",
projectName: "test-project",
});
// Should have: getIssue (for URL), transitionLabel, closeIssue
assert.ok(h.provider.callsTo("getIssue").length >= 1, "Should call getIssue");
assert.strictEqual(h.provider.callsTo("transitionLabel").length, 1);
assert.strictEqual(h.provider.callsTo("closeIssue").length, 1);
// Verify transition args
const transition = h.provider.callsTo("transitionLabel")[0];
assert.strictEqual(transition.args.issueId, 90);
assert.strictEqual(transition.args.from, "Testing");
assert.strictEqual(transition.args.to, "Done");
});
});
});

View File

@@ -8,8 +8,11 @@ import type { StateLabel, IssueProvider } from "../providers/provider.js";
import { deactivateWorker } from "../projects.js";
import { runCommand } from "../run-command.js";
import { notify, getNotificationConfig } from "../notify.js";
import { log as auditLog } from "../audit.js";
import { loadConfig } from "../config/index.js";
import {
DEFAULT_WORKFLOW,
Action,
getCompletionRule,
getNextStateDescription,
getCompletionEmoji,
@@ -72,18 +75,23 @@ export async function executeCompletion(opts: {
const rule = getCompletionRule(workflow, role, result);
if (!rule) throw new Error(`No completion rule for ${key}`);
const { timeouts } = await loadConfig(workspaceDir, projectName);
let prUrl = opts.prUrl;
// Git pull (dev:done)
if (rule.gitPull) {
try {
await runCommand(["git", "pull"], { timeoutMs: 30_000, cwd: repoPath });
} catch { /* best-effort */ }
}
// Auto-detect PR URL (dev:done)
if (rule.detectPr && !prUrl) {
try { prUrl = await provider.getMergedMRUrl(issueId) ?? undefined; } catch { /* ignore */ }
// Execute pre-notification actions
for (const action of rule.actions) {
switch (action) {
case Action.GIT_PULL:
try { await runCommand(["git", "pull"], { timeoutMs: timeouts.gitPullMs, cwd: repoPath }); } catch (err) {
auditLog(workspaceDir, "pipeline_warning", { step: "gitPull", issue: issueId, role, error: (err as Error).message ?? String(err) }).catch(() => {});
}
break;
case Action.DETECT_PR:
if (!prUrl) { try { prUrl = await provider.getMergedMRUrl(issueId) ?? undefined; } catch (err) {
auditLog(workspaceDir, "pipeline_warning", { step: "detectPr", issue: issueId, role, error: (err as Error).message ?? String(err) }).catch(() => {});
} }
break;
}
}
// Get issue early (for URL in notification)
@@ -113,15 +121,25 @@ export async function executeCompletion(opts: {
channel: channel ?? "telegram",
runtime,
},
).catch(() => { /* non-fatal */ });
).catch((err) => {
auditLog(workspaceDir, "pipeline_warning", { step: "notify", issue: issueId, role, error: (err as Error).message ?? String(err) }).catch(() => {});
});
// Deactivate worker + transition label
await deactivateWorker(workspaceDir, groupId, role);
await provider.transitionLabel(issueId, rule.from as StateLabel, rule.to as StateLabel);
// Close/reopen
if (rule.closeIssue) await provider.closeIssue(issueId);
if (rule.reopenIssue) await provider.reopenIssue(issueId);
// Execute post-transition actions
for (const action of rule.actions) {
switch (action) {
case Action.CLOSE_ISSUE:
await provider.closeIssue(issueId);
break;
case Action.REOPEN_ISSUE:
await provider.reopenIssue(issueId);
break;
}
}
// Build announcement using workflow-derived emoji
const emoji = getCompletionEmoji(role, result);
@@ -138,7 +156,7 @@ export async function executeCompletion(opts: {
nextState,
prUrl,
issueUrl: issue.web_url,
issueClosed: rule.closeIssue,
issueReopened: rule.reopenIssue,
issueClosed: rule.actions.includes(Action.CLOSE_ISSUE),
issueReopened: rule.actions.includes(Action.REOPEN_ISSUE),
};
}

View File

@@ -0,0 +1,88 @@
/**
* queue-scan.ts — Issue queue scanning helpers.
*
* Shared by: tick (projectTick), work-start (auto-pickup), and other consumers
* that need to find queued issues or detect roles/levels from labels.
*/
import type { Issue, StateLabel } from "../providers/provider.js";
import type { IssueProvider } from "../providers/provider.js";
import { getLevelsForRole, getAllLevels } from "../roles/index.js";
import {
getQueueLabels,
getAllQueueLabels,
detectRoleFromLabel as workflowDetectRole,
type WorkflowConfig,
type Role,
} from "../workflow.js";
// ---------------------------------------------------------------------------
// Label detection
// ---------------------------------------------------------------------------
export function detectLevelFromLabels(labels: string[]): string | null {
const lower = labels.map((l) => l.toLowerCase());
// Match role.level labels (e.g., "dev.senior", "qa.mid", "architect.junior")
for (const l of lower) {
const dot = l.indexOf(".");
if (dot === -1) continue;
const role = l.slice(0, dot);
const level = l.slice(dot + 1);
const roleLevels = getLevelsForRole(role);
if (roleLevels.includes(level)) return level;
}
// Fallback: plain level name
const all = getAllLevels();
return all.find((l) => lower.includes(l)) ?? null;
}
/**
* Detect role from a label using workflow config.
*/
export function detectRoleFromLabel(
label: StateLabel,
workflow: WorkflowConfig,
): Role | null {
return workflowDetectRole(workflow, label);
}
// ---------------------------------------------------------------------------
// Issue queue queries
// ---------------------------------------------------------------------------
export async function findNextIssueForRole(
provider: Pick<IssueProvider, "listIssuesByLabel">,
role: Role,
workflow: WorkflowConfig,
): Promise<{ issue: Issue; label: StateLabel } | null> {
const labels = getQueueLabels(workflow, role);
for (const label of labels) {
try {
const issues = await provider.listIssuesByLabel(label);
if (issues.length > 0) return { issue: issues[issues.length - 1], label };
} catch { /* continue */ }
}
return null;
}
/**
* Find next issue for any role (optional filter). Used by work_start for auto-detection.
*/
export async function findNextIssue(
provider: Pick<IssueProvider, "listIssuesByLabel">,
role: Role | undefined,
workflow: WorkflowConfig,
): Promise<{ issue: Issue; label: StateLabel } | null> {
const labels = role
? getQueueLabels(workflow, role)
: getAllQueueLabels(workflow);
for (const label of labels) {
try {
const issues = await provider.listIssuesByLabel(label);
if (issues.length > 0) return { issue: issues[issues.length - 1], label };
} catch { /* continue */ }
}
return null;
}

View File

@@ -9,6 +9,7 @@ import { createProvider } from "../providers/index.js";
import type { Project } from "../projects.js";
import {
DEFAULT_WORKFLOW,
StateType,
type WorkflowConfig,
type Role,
} from "../workflow.js";
@@ -27,7 +28,7 @@ export function getQueueLabelsWithPriority(
const labels: Array<{ label: string; priority: number; role?: Role }> = [];
for (const state of Object.values(workflow.states)) {
if (state.type === "queue") {
if (state.type === StateType.QUEUE) {
labels.push({
label: state.label,
priority: state.priority ?? 0,

98
lib/services/review.ts Normal file
View File

@@ -0,0 +1,98 @@
/**
* review.ts — Poll review-type states for PR status changes.
*
* Scans review states in the workflow and transitions issues
* whose PR check condition (merged/approved) is met.
* Called by the heartbeat service during its periodic sweep.
*/
import type { IssueProvider } from "../providers/provider.js";
import { PrState } from "../providers/provider.js";
import {
Action,
ReviewCheck,
WorkflowEvent,
StateType,
type WorkflowConfig,
type StateConfig,
} from "../workflow.js";
import { runCommand } from "../run-command.js";
import { log as auditLog } from "../audit.js";
/**
* Scan review-type states and transition issues whose PR check condition is met.
* Returns the number of transitions made.
*/
export async function reviewPass(opts: {
workspaceDir: string;
groupId: string;
workflow: WorkflowConfig;
provider: IssueProvider;
repoPath: string;
gitPullTimeoutMs?: number;
}): Promise<number> {
const { workspaceDir, groupId, workflow, provider, repoPath, gitPullTimeoutMs = 30_000 } = opts;
let transitions = 0;
// Find all review-type states
const reviewStates = Object.entries(workflow.states)
.filter(([, s]) => s.type === StateType.REVIEW) as [string, StateConfig][];
for (const [stateKey, state] of reviewStates) {
if (!state.on || !state.check) continue;
const issues = await provider.listIssuesByLabel(state.label);
for (const issue of issues) {
const status = await provider.getPrStatus(issue.iid);
const conditionMet =
(state.check === ReviewCheck.PR_MERGED && status.state === PrState.MERGED) ||
(state.check === ReviewCheck.PR_APPROVED && (status.state === PrState.APPROVED || status.state === PrState.MERGED));
if (!conditionMet) continue;
// Find the success transition (first non-BLOCKED event)
const successEvent = Object.keys(state.on).find((e) => e !== WorkflowEvent.BLOCKED);
if (!successEvent) continue;
const transition = state.on[successEvent];
const targetKey = typeof transition === "string" ? transition : transition.target;
const actions = typeof transition === "object" ? transition.actions : undefined;
const targetState = workflow.states[targetKey];
if (!targetState) continue;
// Execute transition actions
if (actions) {
for (const action of actions) {
switch (action) {
case Action.GIT_PULL:
try { await runCommand(["git", "pull"], { timeoutMs: gitPullTimeoutMs, cwd: repoPath }); } catch { /* best-effort */ }
break;
case Action.CLOSE_ISSUE:
await provider.closeIssue(issue.iid);
break;
case Action.REOPEN_ISSUE:
await provider.reopenIssue(issue.iid);
break;
}
}
}
// Transition label
await provider.transitionLabel(issue.iid, state.label, targetState.label);
await auditLog(workspaceDir, "review_transition", {
groupId,
issueId: issue.iid,
from: state.label,
to: targetState.label,
check: state.check,
prState: status.state,
prUrl: status.url,
});
transitions++;
}
}
return transitions;
}

View File

@@ -11,84 +11,15 @@ import { createProvider } from "../providers/index.js";
import { selectLevel } from "../model-selector.js";
import { getWorker, getSessionForLevel, readProjects } from "../projects.js";
import { dispatchTask } from "../dispatch.js";
import { getLevelsForRole, getAllLevels, roleForLevel } from "../roles/index.js";
import { roleForLevel } from "../roles/index.js";
import { loadConfig } from "../config/index.js";
import {
getQueueLabels,
getAllQueueLabels,
ExecutionMode,
getActiveLabel,
detectRoleFromLabel as workflowDetectRole,
type WorkflowConfig,
type Role,
} from "../workflow.js";
// ---------------------------------------------------------------------------
// Shared helpers (used by tick, work-start, auto-pickup)
// ---------------------------------------------------------------------------
export function detectLevelFromLabels(labels: string[]): string | null {
const lower = labels.map((l) => l.toLowerCase());
// Match role.level labels (e.g., "dev.senior", "qa.mid", "architect.junior")
for (const l of lower) {
const dot = l.indexOf(".");
if (dot === -1) continue;
const role = l.slice(0, dot);
const level = l.slice(dot + 1);
const roleLevels = getLevelsForRole(role);
if (roleLevels.includes(level)) return level;
}
// Fallback: plain level name
const all = getAllLevels();
return all.find((l) => lower.includes(l)) ?? null;
}
/**
* Detect role from a label using workflow config.
*/
export function detectRoleFromLabel(
label: StateLabel,
workflow: WorkflowConfig,
): Role | null {
return workflowDetectRole(workflow, label);
}
export async function findNextIssueForRole(
provider: Pick<IssueProvider, "listIssuesByLabel">,
role: Role,
workflow: WorkflowConfig,
): Promise<{ issue: Issue; label: StateLabel } | null> {
const labels = getQueueLabels(workflow, role);
for (const label of labels) {
try {
const issues = await provider.listIssuesByLabel(label);
if (issues.length > 0) return { issue: issues[issues.length - 1], label };
} catch { /* continue */ }
}
return null;
}
/**
* Find next issue for any role (optional filter). Used by work_start for auto-detection.
*/
export async function findNextIssue(
provider: Pick<IssueProvider, "listIssuesByLabel">,
role: Role | undefined,
workflow: WorkflowConfig,
): Promise<{ issue: Issue; label: StateLabel } | null> {
const labels = role
? getQueueLabels(workflow, role)
: getAllQueueLabels(workflow);
for (const label of labels) {
try {
const issues = await provider.listIssuesByLabel(label);
if (issues.length > 0) return { issue: issues[issues.length - 1], label };
} catch { /* continue */ }
}
return null;
}
import { detectLevelFromLabels, findNextIssueForRole } from "./queue-scan.js";
// ---------------------------------------------------------------------------
// projectTick
@@ -146,7 +77,7 @@ export async function projectTick(opts: {
const workflow = opts.workflow ?? resolvedConfig.workflow;
const provider = opts.provider ?? (await createProvider({ repo: project.repo, provider: project.provider })).provider;
const roleExecution = project.roleExecution ?? "parallel";
const roleExecution = project.roleExecution ?? ExecutionMode.PARALLEL;
const enabledRoles = Object.entries(resolvedConfig.roles)
.filter(([, r]) => r.enabled)
.map(([id]) => id);
@@ -173,7 +104,7 @@ export async function projectTick(opts: {
}
// Check sequential role execution: any other role must be inactive
const otherRoles = enabledRoles.filter((r: string) => r !== role);
if (roleExecution === "sequential" && otherRoles.some((r: string) => getWorker(fresh, r).active)) {
if (roleExecution === ExecutionMode.SEQUENTIAL && otherRoles.some((r: string) => getWorker(fresh, r).active)) {
skipped.push({ role, reason: "Sequential: other role active" });
continue;
}

View File

@@ -6,6 +6,7 @@
*/
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { HEARTBEAT_DEFAULTS } from "../services/heartbeat.js";
import type { ExecutionMode } from "../workflow.js";
/**
* Write DevClaw plugin config to openclaw.json plugins section.
@@ -21,7 +22,7 @@ import { HEARTBEAT_DEFAULTS } from "../services/heartbeat.js";
export async function writePluginConfig(
api: OpenClawPluginApi,
agentId?: string,
projectExecution?: "parallel" | "sequential",
projectExecution?: ExecutionMode,
): Promise<void> {
const config = api.runtime.config.loadConfig() as Record<string, unknown>;

View File

@@ -14,6 +14,7 @@ import { createAgent, resolveWorkspacePath } from "./agent.js";
import { writePluginConfig } from "./config.js";
import { scaffoldWorkspace } from "./workspace.js";
import { DATA_DIR } from "./migrate-layout.js";
import type { ExecutionMode } from "../workflow.js";
export type ModelConfig = Record<string, Record<string, string>>;
@@ -33,7 +34,7 @@ export type SetupOpts = {
/** Model overrides per role.level. Missing levels use defaults. */
models?: Record<string, Partial<Record<string, string>>>;
/** Plugin-level project execution mode: parallel or sequential. Default: parallel. */
projectExecution?: "parallel" | "sequential";
projectExecution?: ExecutionMode;
};
export type SetupResult = {

View File

@@ -21,10 +21,12 @@ Read the comments carefully — they often contain clarifications, decisions, or
- Work in a git worktree (never switch branches in the main repo)
- Run tests before completing
- Create an MR/PR to the base branch and merge it
- Create an MR/PR to the base branch
- **IMPORTANT:** Do NOT use closing keywords in PR/MR descriptions (no "Closes #X", "Fixes #X", "Resolves #X"). Use "As described in issue #X" or "Addresses issue #X" instead. DevClaw manages issue state — auto-closing bypasses QA.
- Clean up the worktree after merging
- When done, call work_finish with role "developer", result "done", and a brief summary
- **Merge or request review:**
- Merge the PR yourself → call work_finish with result "done"
- Leave the PR open for human review → call work_finish with result "review" (the heartbeat will auto-advance when the PR is merged)
- Clean up the worktree after merging (if you merged)
- If you discover unrelated bugs, call task_create to file them
- Do NOT call work_start, status, health, or project_register
`;
@@ -144,7 +146,8 @@ Skip the orchestrator section. Follow your task message and role instructions (a
When you are done, **call \`work_finish\` yourself** — do not just announce in text.
- **DEVELOPER done:** \`work_finish({ role: "developer", result: "done", projectGroupId: "<from task message>", summary: "<brief summary>" })\`
- **DEVELOPER done (merged):** \`work_finish({ role: "developer", result: "done", projectGroupId: "<from task message>", summary: "<brief summary>" })\`
- **DEVELOPER review (PR open):** \`work_finish({ role: "developer", result: "review", projectGroupId: "<from task message>", summary: "<brief summary>" })\`
- **TESTER pass:** \`work_finish({ role: "tester", result: "pass", projectGroupId: "<from task message>", summary: "<brief summary>" })\`
- **TESTER fail:** \`work_finish({ role: "tester", result: "fail", projectGroupId: "<from task message>", summary: "<specific issues>" })\`
- **TESTER refine:** \`work_finish({ role: "tester", result: "refine", projectGroupId: "<from task message>", summary: "<what needs human input>" })\`
@@ -224,10 +227,12 @@ All orchestration goes through these tools. You do NOT manually manage sessions,
\`\`\`
Planning → To Do → Doing → To Test → Testing → Done
To Improve → Doing (fix cycle)
Refining (human decision)
In Review ─────┘ (auto-advances when PR merged)
To Improve → Doing (fix cycle)
Refining (human decision)
To Design → Designing → Planning (design complete)
\`\`\`
@@ -257,6 +262,7 @@ All roles (Developer, Tester, Architect) use the same level scheme. Levels descr
Workers call \`work_finish\` themselves — the label transition, state update, and audit log happen atomically. The heartbeat service will pick up the next task on its next cycle:
- Developer "done" → issue moves to "To Test" → scheduler dispatches Tester
- Developer "review" → issue moves to "In Review" → heartbeat polls PR status → auto-advances to "To Test" when merged
- Tester "fail" → issue moves to "To Improve" → scheduler dispatches Developer
- Tester "pass" → Done, no further dispatch
- Tester "refine" / blocked → needs human input
@@ -270,7 +276,7 @@ Workers receive role-specific instructions appended to their task message. These
### Heartbeats
**Do nothing.** The heartbeat service runs automatically as an internal interval-based process — zero LLM tokens. It handles health checks (zombie detection, stale workers) and queue dispatch (filling free worker slots by priority) every 60 seconds by default. Configure via \`plugins.entries.devclaw.config.work_heartbeat\` in openclaw.json.
**Do nothing.** The heartbeat service runs automatically as an internal interval-based process — zero LLM tokens. It handles health checks (zombie detection, stale workers), review polling (auto-advancing "In Review" issues when PRs are merged), and queue dispatch (filling free worker slots by priority) every 60 seconds by default. Configure via \`plugins.entries.devclaw.config.work_heartbeat\` in openclaw.json.
### Safety
@@ -333,6 +339,9 @@ workflow:
COMPLETE:
target: toTest
actions: [gitPull, detectPr]
REVIEW:
target: reviewing
actions: [detectPr]
BLOCKED: refining
toTest:
type: queue
@@ -370,6 +379,16 @@ workflow:
color: "#f39c12"
on:
APPROVE: todo
reviewing:
type: review
label: In Review
color: "#c5def5"
check: prMerged
on:
APPROVED:
target: toTest
actions: [gitPull]
BLOCKED: refining
done:
type: terminal
label: Done

292
lib/testing/harness.ts Normal file
View File

@@ -0,0 +1,292 @@
/**
* Test harness — scaffolds a temporary workspace with projects.json,
* installs a mock runCommand, and provides helpers for E2E pipeline tests.
*
* Usage:
* const h = await createTestHarness({ ... });
* try { ... } finally { await h.cleanup(); }
*/
import fs from "node:fs/promises";
import path from "node:path";
import os from "node:os";
import { initRunCommand } from "../run-command.js";
import { writeProjects, type ProjectsData, type Project, emptyWorkerState } from "../projects.js";
import { DEFAULT_WORKFLOW, type WorkflowConfig } from "../workflow.js";
import { registerBootstrapHook } from "../bootstrap-hook.js";
import { TestProvider } from "./test-provider.js";
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
// ---------------------------------------------------------------------------
// Bootstrap file type (mirrors OpenClaw's internal type)
// ---------------------------------------------------------------------------
export type BootstrapFile = {
name: string;
path: string;
content?: string;
missing: boolean;
};
// ---------------------------------------------------------------------------
// Command interceptor
// ---------------------------------------------------------------------------
export type CapturedCommand = {
argv: string[];
opts: { timeoutMs: number; cwd?: string };
/** Extracted from gateway `agent` call params, if applicable. */
taskMessage?: string;
/** Extracted from gateway `sessions.patch` params, if applicable. */
sessionPatch?: { key: string; model: string };
};
export type CommandInterceptor = {
/** All captured commands, in order. */
commands: CapturedCommand[];
/** Filter commands by first argv element. */
commandsFor(cmd: string): CapturedCommand[];
/** Get all task messages sent via `openclaw gateway call agent`. */
taskMessages(): string[];
/** Get all session patches. */
sessionPatches(): Array<{ key: string; model: string }>;
/** Reset captured commands. */
reset(): void;
};
function createCommandInterceptor(): {
interceptor: CommandInterceptor;
handler: (argv: string[], opts: number | { timeoutMs: number; cwd?: string }) => Promise<{ stdout: string; stderr: string; code: number | null; signal: null; killed: false }>;
} {
const commands: CapturedCommand[] = [];
const handler = async (
argv: string[],
optsOrTimeout: number | { timeoutMs: number; cwd?: string },
) => {
const opts = typeof optsOrTimeout === "number"
? { timeoutMs: optsOrTimeout }
: optsOrTimeout;
const captured: CapturedCommand = { argv, opts };
// Parse gateway agent calls to extract task message
if (argv[0] === "openclaw" && argv[1] === "gateway" && argv[2] === "call") {
const rpcMethod = argv[3];
const paramsIdx = argv.indexOf("--params");
if (paramsIdx !== -1 && argv[paramsIdx + 1]) {
try {
const params = JSON.parse(argv[paramsIdx + 1]);
if (rpcMethod === "agent" && params.message) {
captured.taskMessage = params.message;
}
if (rpcMethod === "sessions.patch") {
captured.sessionPatch = { key: params.key, model: params.model };
}
} catch { /* ignore parse errors */ }
}
}
commands.push(captured);
return { stdout: "{}", stderr: "", code: 0, signal: null as null, killed: false as const };
};
const interceptor: CommandInterceptor = {
commands,
commandsFor(cmd: string) {
return commands.filter((c) => c.argv[0] === cmd);
},
taskMessages() {
return commands
.filter((c) => c.taskMessage !== undefined)
.map((c) => c.taskMessage!);
},
sessionPatches() {
return commands
.filter((c) => c.sessionPatch !== undefined)
.map((c) => c.sessionPatch!);
},
reset() {
commands.length = 0;
},
};
return { interceptor, handler };
}
// ---------------------------------------------------------------------------
// Test harness
// ---------------------------------------------------------------------------
export type TestHarness = {
/** Temporary workspace directory. */
workspaceDir: string;
/** In-memory issue provider. */
provider: TestProvider;
/** Command interceptor — captures all runCommand calls. */
commands: CommandInterceptor;
/** The project group ID used for test data. */
groupId: string;
/** The project data. */
project: Project;
/** Workflow config. */
workflow: WorkflowConfig;
/** Write updated projects data to disk. */
writeProjects(data: ProjectsData): Promise<void>;
/** Read current projects data from disk. */
readProjects(): Promise<ProjectsData>;
/**
* Write a role prompt file to the workspace.
* @param role - Role name (e.g. "developer", "tester")
* @param content - Prompt file content
* @param projectName - If provided, writes project-specific prompt; otherwise writes default.
*/
writePrompt(role: string, content: string, projectName?: string): Promise<void>;
/**
* Simulate the agent:bootstrap hook firing for a session key.
* Registers the real hook with a mock API, fires it, returns the injected bootstrap files.
* This tests the full hook chain: session key → parse → load instructions → inject.
*/
simulateBootstrap(sessionKey: string): Promise<BootstrapFile[]>;
/** Clean up temp directory. */
cleanup(): Promise<void>;
};
export type HarnessOptions = {
/** Project name (default: "test-project"). */
projectName?: string;
/** Group ID (default: "-1234567890"). */
groupId?: string;
/** Repo path (default: "/tmp/test-repo"). */
repo?: string;
/** Base branch (default: "main"). */
baseBranch?: string;
/** Workflow config (default: DEFAULT_WORKFLOW). */
workflow?: WorkflowConfig;
/** Initial worker state overrides. */
workers?: Record<string, Partial<import("../projects.js").WorkerState>>;
/** Additional projects to seed. */
extraProjects?: Record<string, Project>;
};
export async function createTestHarness(opts?: HarnessOptions): Promise<TestHarness> {
const {
projectName = "test-project",
groupId = "-1234567890",
repo = "/tmp/test-repo",
baseBranch = "main",
workflow = DEFAULT_WORKFLOW,
workers: workerOverrides,
extraProjects,
} = opts ?? {};
// Create temp workspace
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-e2e-"));
const dataDir = path.join(workspaceDir, "devclaw");
const logDir = path.join(dataDir, "log");
await fs.mkdir(logDir, { recursive: true });
// Build project
const defaultWorkers: Record<string, import("../projects.js").WorkerState> = {
developer: emptyWorkerState(["junior", "medior", "senior"]),
tester: emptyWorkerState(["junior", "medior", "senior"]),
architect: emptyWorkerState(["junior", "senior"]),
};
// Apply worker overrides
if (workerOverrides) {
for (const [role, overrides] of Object.entries(workerOverrides)) {
if (defaultWorkers[role]) {
defaultWorkers[role] = { ...defaultWorkers[role], ...overrides };
} else {
defaultWorkers[role] = { ...emptyWorkerState([]), ...overrides };
}
}
}
const project: Project = {
name: projectName,
repo,
groupName: "Test Group",
deployUrl: "",
baseBranch,
deployBranch: baseBranch,
provider: "github",
workers: defaultWorkers,
};
const projectsData: ProjectsData = {
projects: {
[groupId]: project,
...extraProjects,
},
};
await writeProjects(workspaceDir, projectsData);
// Install mock runCommand
const { interceptor, handler } = createCommandInterceptor();
initRunCommand({
runtime: {
system: { runCommandWithTimeout: handler },
},
} as unknown as OpenClawPluginApi);
// Create test provider
const provider = new TestProvider({ workflow });
return {
workspaceDir,
provider,
commands: interceptor,
groupId,
project,
workflow,
async writeProjects(data: ProjectsData) {
await writeProjects(workspaceDir, data);
},
async readProjects() {
const { readProjects } = await import("../projects.js");
return readProjects(workspaceDir);
},
async writePrompt(role: string, content: string, forProject?: string) {
const dir = forProject
? path.join(dataDir, "projects", forProject, "prompts")
: path.join(dataDir, "prompts");
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(path.join(dir, `${role}.md`), content, "utf-8");
},
async simulateBootstrap(sessionKey: string) {
// Capture the hook callback by mocking api.registerHook
let hookCallback: ((event: any) => Promise<void>) | null = null;
const mockApi = {
registerHook(_name: string, cb: (event: any) => Promise<void>) {
hookCallback = cb;
},
logger: {
info() {},
warn() {},
error() {},
},
} as unknown as OpenClawPluginApi;
registerBootstrapHook(mockApi);
if (!hookCallback) throw new Error("registerBootstrapHook did not register a callback");
// Build a bootstrap event matching what OpenClaw sends
const bootstrapFiles: BootstrapFile[] = [];
await hookCallback({
sessionKey,
context: {
workspaceDir,
bootstrapFiles,
},
});
return bootstrapFiles;
},
async cleanup() {
await fs.rm(workspaceDir, { recursive: true, force: true });
},
};
}

16
lib/testing/index.ts Normal file
View File

@@ -0,0 +1,16 @@
/**
* testing/ — Test infrastructure for DevClaw integration tests.
*
* Exports:
* - TestProvider: In-memory IssueProvider with call tracking
* - createTestHarness: Scaffolds temp workspace + mock runCommand
*/
export { TestProvider, type ProviderCall } from "./test-provider.js";
export {
createTestHarness,
type TestHarness,
type HarnessOptions,
type CommandInterceptor,
type CapturedCommand,
type BootstrapFile,
} from "./harness.js";

View File

@@ -0,0 +1,224 @@
/**
* TestProvider — In-memory IssueProvider for integration tests.
*
* Tracks all method calls for assertion. Issues are stored in a simple map.
* No external dependencies — pure TypeScript.
*/
import type {
IssueProvider,
Issue,
StateLabel,
IssueComment,
PrStatus,
} from "../providers/provider.js";
import { getStateLabels } from "../workflow.js";
import { DEFAULT_WORKFLOW, type WorkflowConfig } from "../workflow.js";
// ---------------------------------------------------------------------------
// Call tracking
// ---------------------------------------------------------------------------
export type ProviderCall =
| { method: "ensureLabel"; args: { name: string; color: string } }
| { method: "ensureAllStateLabels"; args: {} }
| { method: "createIssue"; args: { title: string; description: string; label: StateLabel; assignees?: string[] } }
| { method: "listIssuesByLabel"; args: { label: StateLabel } }
| { method: "getIssue"; args: { issueId: number } }
| { method: "listComments"; args: { issueId: number } }
| { method: "transitionLabel"; args: { issueId: number; from: StateLabel; to: StateLabel } }
| { method: "closeIssue"; args: { issueId: number } }
| { method: "reopenIssue"; args: { issueId: number } }
| { method: "hasMergedMR"; args: { issueId: number } }
| { method: "getMergedMRUrl"; args: { issueId: number } }
| { method: "getPrStatus"; args: { issueId: number } }
| { method: "addComment"; args: { issueId: number; body: string } }
| { method: "healthCheck"; args: {} };
// ---------------------------------------------------------------------------
// TestProvider
// ---------------------------------------------------------------------------
export class TestProvider implements IssueProvider {
/** All issues keyed by iid. */
issues = new Map<number, Issue>();
/** Comments per issue. */
comments = new Map<number, IssueComment[]>();
/** Labels that have been ensured. */
labels = new Map<string, string>();
/** PR status overrides per issue. Default: { state: "closed", url: null }. */
prStatuses = new Map<number, PrStatus>();
/** Merged MR URLs per issue. */
mergedMrUrls = new Map<number, string>();
/** All calls, in order. */
calls: ProviderCall[] = [];
private nextIssueId = 1;
private workflow: WorkflowConfig;
constructor(opts?: { workflow?: WorkflowConfig }) {
this.workflow = opts?.workflow ?? DEFAULT_WORKFLOW;
}
// -------------------------------------------------------------------------
// Test helpers
// -------------------------------------------------------------------------
/** Create an issue directly in the store (bypasses createIssue tracking). */
seedIssue(overrides: Partial<Issue> & { iid: number }): Issue {
const issue: Issue = {
iid: overrides.iid,
title: overrides.title ?? `Issue #${overrides.iid}`,
description: overrides.description ?? "",
labels: overrides.labels ?? [],
state: overrides.state ?? "opened",
web_url: overrides.web_url ?? `https://example.com/issues/${overrides.iid}`,
};
this.issues.set(issue.iid, issue);
if (issue.iid >= this.nextIssueId) this.nextIssueId = issue.iid + 1;
return issue;
}
/** Set PR status for an issue (used by review pass tests). */
setPrStatus(issueId: number, status: PrStatus): void {
this.prStatuses.set(issueId, status);
}
/** Get calls filtered by method name. */
callsTo<M extends ProviderCall["method"]>(
method: M,
): Extract<ProviderCall, { method: M }>[] {
return this.calls.filter((c) => c.method === method) as any;
}
/** Reset call tracking (keeps issue state). */
resetCalls(): void {
this.calls = [];
}
/** Full reset — clear everything. */
reset(): void {
this.issues.clear();
this.comments.clear();
this.labels.clear();
this.prStatuses.clear();
this.mergedMrUrls.clear();
this.calls = [];
this.nextIssueId = 1;
}
// -------------------------------------------------------------------------
// IssueProvider implementation
// -------------------------------------------------------------------------
async ensureLabel(name: string, color: string): Promise<void> {
this.calls.push({ method: "ensureLabel", args: { name, color } });
this.labels.set(name, color);
}
async ensureAllStateLabels(): Promise<void> {
this.calls.push({ method: "ensureAllStateLabels", args: {} });
const stateLabels = getStateLabels(this.workflow);
for (const label of stateLabels) {
this.labels.set(label, "#000000");
}
}
async createIssue(
title: string,
description: string,
label: StateLabel,
assignees?: string[],
): Promise<Issue> {
this.calls.push({ method: "createIssue", args: { title, description, label, assignees } });
const iid = this.nextIssueId++;
const issue: Issue = {
iid,
title,
description,
labels: [label],
state: "opened",
web_url: `https://example.com/issues/${iid}`,
};
this.issues.set(iid, issue);
return issue;
}
async listIssuesByLabel(label: StateLabel): Promise<Issue[]> {
this.calls.push({ method: "listIssuesByLabel", args: { label } });
return [...this.issues.values()].filter((i) => i.labels.includes(label));
}
async getIssue(issueId: number): Promise<Issue> {
this.calls.push({ method: "getIssue", args: { issueId } });
const issue = this.issues.get(issueId);
if (!issue) throw new Error(`Issue #${issueId} not found in TestProvider`);
return issue;
}
async listComments(issueId: number): Promise<IssueComment[]> {
this.calls.push({ method: "listComments", args: { issueId } });
return this.comments.get(issueId) ?? [];
}
async transitionLabel(
issueId: number,
from: StateLabel,
to: StateLabel,
): Promise<void> {
this.calls.push({ method: "transitionLabel", args: { issueId, from, to } });
const issue = this.issues.get(issueId);
if (!issue) throw new Error(`Issue #${issueId} not found in TestProvider`);
// Remove all state labels, add the new one
const stateLabels = getStateLabels(this.workflow);
issue.labels = issue.labels.filter((l) => !stateLabels.includes(l));
issue.labels.push(to);
}
async closeIssue(issueId: number): Promise<void> {
this.calls.push({ method: "closeIssue", args: { issueId } });
const issue = this.issues.get(issueId);
if (issue) issue.state = "closed";
}
async reopenIssue(issueId: number): Promise<void> {
this.calls.push({ method: "reopenIssue", args: { issueId } });
const issue = this.issues.get(issueId);
if (issue) issue.state = "opened";
}
hasStateLabel(issue: Issue, expected: StateLabel): boolean {
return issue.labels.includes(expected);
}
getCurrentStateLabel(issue: Issue): StateLabel | null {
const stateLabels = getStateLabels(this.workflow);
return stateLabels.find((l) => issue.labels.includes(l)) ?? null;
}
async hasMergedMR(issueId: number): Promise<boolean> {
this.calls.push({ method: "hasMergedMR", args: { issueId } });
return this.mergedMrUrls.has(issueId);
}
async getMergedMRUrl(issueId: number): Promise<string | null> {
this.calls.push({ method: "getMergedMRUrl", args: { issueId } });
return this.mergedMrUrls.get(issueId) ?? null;
}
async getPrStatus(issueId: number): Promise<PrStatus> {
this.calls.push({ method: "getPrStatus", args: { issueId } });
return this.prStatuses.get(issueId) ?? { state: "closed", url: null };
}
async addComment(issueId: number, body: string): Promise<void> {
this.calls.push({ method: "addComment", args: { issueId, body } });
const existing = this.comments.get(issueId) ?? [];
existing.push({ author: "test", body, created_at: new Date().toISOString() });
this.comments.set(issueId, existing);
}
async healthCheck(): Promise<boolean> {
this.calls.push({ method: "healthCheck", args: {} });
return true;
}
}

View File

@@ -15,6 +15,7 @@ import { resolveRepoPath } from "../projects.js";
import { createProvider } from "../providers/index.js";
import { log as auditLog } from "../audit.js";
import { getAllRoleIds, getLevelsForRole } from "../roles/index.js";
import { ExecutionMode } from "../workflow.js";
import { DEFAULT_ROLE_INSTRUCTIONS } from "../templates.js";
import { DATA_DIR } from "../setup/migrate-layout.js";
@@ -84,7 +85,7 @@ export function createProjectRegisterTool() {
},
roleExecution: {
type: "string",
enum: ["parallel", "sequential"],
enum: Object.values(ExecutionMode),
description: "Project-level role execution mode: parallel (DEV and QA can work simultaneously) or sequential (only one role active at a time). Defaults to parallel.",
},
},
@@ -99,7 +100,7 @@ export function createProjectRegisterTool() {
const baseBranch = params.baseBranch as string;
const deployBranch = (params.deployBranch as string) ?? baseBranch;
const deployUrl = (params.deployUrl as string) ?? "";
const roleExecution = (params.roleExecution as "parallel" | "sequential") ?? "parallel";
const roleExecution = (params.roleExecution as ExecutionMode) ?? ExecutionMode.PARALLEL;
const workspaceDir = ctx.workspaceDir;
if (!workspaceDir) {

View File

@@ -9,6 +9,7 @@ import { jsonResult } from "openclaw/plugin-sdk";
import type { ToolContext } from "../types.js";
import { runSetup, type SetupOpts } from "../setup/index.js";
import { getAllDefaultModels, getAllRoleIds, getLevelsForRole } from "../roles/index.js";
import { ExecutionMode } from "../workflow.js";
export function createSetupTool(api: OpenClawPluginApi) {
return (ctx: ToolContext) => ({
@@ -51,7 +52,7 @@ export function createSetupTool(api: OpenClawPluginApi) {
},
projectExecution: {
type: "string",
enum: ["parallel", "sequential"],
enum: Object.values(ExecutionMode),
description: "Project execution mode. Default: parallel.",
},
},
@@ -68,8 +69,7 @@ export function createSetupTool(api: OpenClawPluginApi) {
workspacePath: params.newAgentName ? undefined : ctx.workspaceDir,
models: params.models as SetupOpts["models"],
projectExecution: params.projectExecution as
| "parallel"
| "sequential"
| ExecutionMode
| undefined,
});

View File

@@ -11,7 +11,7 @@ import { readProjects, getProject } from "../projects.js";
import { log as auditLog } from "../audit.js";
import { fetchProjectQueues, getTotalQueuedCount, getQueueLabelsWithPriority } from "../services/queue.js";
import { requireWorkspaceDir, getPluginConfig } from "../tool-helpers.js";
import { loadWorkflow } from "../workflow.js";
import { loadWorkflow, ExecutionMode } from "../workflow.js";
export function createStatusTool(api: OpenClawPluginApi) {
return (ctx: ToolContext) => ({
@@ -30,7 +30,7 @@ export function createStatusTool(api: OpenClawPluginApi) {
const groupId = params.projectGroupId as string | undefined;
const pluginConfig = getPluginConfig(api);
const projectExecution = (pluginConfig?.projectExecution as string) ?? "parallel";
const projectExecution = (pluginConfig?.projectExecution as string) ?? ExecutionMode.PARALLEL;
// Load workspace-level workflow (per-project loaded inside map)
const workflow = await loadWorkflow(workspaceDir);
@@ -66,7 +66,7 @@ export function createStatusTool(api: OpenClawPluginApi) {
return {
name: project.name,
groupId: pid,
roleExecution: project.roleExecution ?? "parallel",
roleExecution: project.roleExecution ?? ExecutionMode.PARALLEL,
workers,
queue: queueCounts,
};

View File

@@ -27,10 +27,11 @@ describe("task_update tool", () => {
"Done",
"To Improve",
"Refining",
"In Review",
];
// In a real test, we'd verify these against the tool's enum
assert.strictEqual(validStates.length, 8);
assert.strictEqual(validStates.length, 9);
});
it("validates required parameters", () => {

View File

@@ -12,10 +12,10 @@ import type { StateLabel } from "../providers/provider.js";
import { selectLevel } from "../model-selector.js";
import { getWorker } from "../projects.js";
import { dispatchTask } from "../dispatch.js";
import { findNextIssue, detectRoleFromLabel, detectLevelFromLabels } from "../services/tick.js";
import { findNextIssue, detectRoleFromLabel, detectLevelFromLabels } from "../services/queue-scan.js";
import { getAllRoleIds, isLevelForRole } from "../roles/index.js";
import { requireWorkspaceDir, resolveProject, resolveProvider, getPluginConfig } from "../tool-helpers.js";
import { loadWorkflow, getActiveLabel } from "../workflow.js";
import { loadWorkflow, getActiveLabel, ExecutionMode } from "../workflow.js";
export function createWorkStartTool(api: OpenClawPluginApi) {
return (ctx: ToolContext) => ({
@@ -70,7 +70,7 @@ export function createWorkStartTool(api: OpenClawPluginApi) {
// Check worker availability
const worker = getWorker(project, role);
if (worker.active) throw new Error(`${role.toUpperCase()} already active on ${project.name} (issue: ${worker.issueId})`);
if ((project.roleExecution ?? "parallel") === "sequential") {
if ((project.roleExecution ?? ExecutionMode.PARALLEL) === ExecutionMode.SEQUENTIAL) {
for (const [otherRole, otherWorker] of Object.entries(project.workers)) {
if (otherRole !== role && otherWorker.active) {
throw new Error(`Sequential roleExecution: ${otherRole.toUpperCase()} is active`);

View File

@@ -13,14 +13,60 @@
// Types
// ---------------------------------------------------------------------------
export type StateType = "queue" | "active" | "hold" | "terminal";
/** Built-in state types. */
export const StateType = {
QUEUE: "queue",
ACTIVE: "active",
HOLD: "hold",
TERMINAL: "terminal",
REVIEW: "review",
} as const;
export type StateType = (typeof StateType)[keyof typeof StateType];
/** Built-in execution modes for role and project parallelism. */
export const ExecutionMode = {
PARALLEL: "parallel",
SEQUENTIAL: "sequential",
} as const;
export type ExecutionMode = (typeof ExecutionMode)[keyof typeof ExecutionMode];
/** Role identifier. Built-in: "developer", "tester", "architect". Extensible via config. */
export type Role = string;
export type TransitionAction = "gitPull" | "detectPr" | "closeIssue" | "reopenIssue";
/** Action identifier. Built-in actions listed in `Action`; custom actions are also valid strings. */
export type TransitionAction = string;
/** Built-in transition actions. Custom actions are also valid — these are just the ones with built-in handlers. */
export const Action = {
GIT_PULL: "gitPull",
DETECT_PR: "detectPr",
CLOSE_ISSUE: "closeIssue",
REOPEN_ISSUE: "reopenIssue",
} as const;
/** Built-in review check types for review states. */
export const ReviewCheck = {
PR_APPROVED: "prApproved",
PR_MERGED: "prMerged",
} as const;
export type ReviewCheckType = (typeof ReviewCheck)[keyof typeof ReviewCheck];
/** Built-in workflow events. */
export const WorkflowEvent = {
PICKUP: "PICKUP",
COMPLETE: "COMPLETE",
REVIEW: "REVIEW",
APPROVED: "APPROVED",
PASS: "PASS",
FAIL: "FAIL",
REFINE: "REFINE",
BLOCKED: "BLOCKED",
APPROVE: "APPROVE",
} as const;
export type TransitionTarget = string | {
target: string;
actions?: TransitionAction[];
description?: string;
};
export type StateConfig = {
@@ -29,6 +75,8 @@ export type StateConfig = {
label: string;
color: string;
priority?: number;
description?: string;
check?: ReviewCheckType;
on?: Record<string, TransitionTarget>;
};
@@ -40,10 +88,7 @@ export type WorkflowConfig = {
export type CompletionRule = {
from: string;
to: string;
gitPull?: boolean;
detectPr?: boolean;
closeIssue?: boolean;
reopenIssue?: boolean;
actions: string[];
};
// ---------------------------------------------------------------------------
@@ -54,84 +99,95 @@ export const DEFAULT_WORKFLOW: WorkflowConfig = {
initial: "planning",
states: {
planning: {
type: "hold",
type: StateType.HOLD,
label: "Planning",
color: "#95a5a6",
on: { APPROVE: "todo" },
on: { [WorkflowEvent.APPROVE]: "todo" },
},
todo: {
type: "queue",
type: StateType.QUEUE,
role: "developer",
label: "To Do",
color: "#428bca",
priority: 1,
on: { PICKUP: "doing" },
on: { [WorkflowEvent.PICKUP]: "doing" },
},
doing: {
type: "active",
type: StateType.ACTIVE,
role: "developer",
label: "Doing",
color: "#f0ad4e",
on: {
COMPLETE: { target: "toTest", actions: ["gitPull", "detectPr"] },
BLOCKED: "refining",
[WorkflowEvent.COMPLETE]: { target: "toTest", actions: [Action.GIT_PULL, Action.DETECT_PR] },
[WorkflowEvent.REVIEW]: { target: "reviewing", actions: [Action.DETECT_PR] },
[WorkflowEvent.BLOCKED]: "refining",
},
},
toTest: {
type: "queue",
type: StateType.QUEUE,
role: "tester",
label: "To Test",
color: "#5bc0de",
priority: 2,
on: { PICKUP: "testing" },
on: { [WorkflowEvent.PICKUP]: "testing" },
},
testing: {
type: "active",
type: StateType.ACTIVE,
role: "tester",
label: "Testing",
color: "#9b59b6",
on: {
PASS: { target: "done", actions: ["closeIssue"] },
FAIL: { target: "toImprove", actions: ["reopenIssue"] },
REFINE: "refining",
BLOCKED: "refining",
[WorkflowEvent.PASS]: { target: "done", actions: [Action.CLOSE_ISSUE] },
[WorkflowEvent.FAIL]: { target: "toImprove", actions: [Action.REOPEN_ISSUE] },
[WorkflowEvent.REFINE]: "refining",
[WorkflowEvent.BLOCKED]: "refining",
},
},
toImprove: {
type: "queue",
type: StateType.QUEUE,
role: "developer",
label: "To Improve",
color: "#d9534f",
priority: 3,
on: { PICKUP: "doing" },
on: { [WorkflowEvent.PICKUP]: "doing" },
},
refining: {
type: "hold",
type: StateType.HOLD,
label: "Refining",
color: "#f39c12",
on: { APPROVE: "todo" },
on: { [WorkflowEvent.APPROVE]: "todo" },
},
reviewing: {
type: StateType.REVIEW,
label: "In Review",
color: "#c5def5",
check: ReviewCheck.PR_MERGED,
on: {
[WorkflowEvent.APPROVED]: { target: "toTest", actions: [Action.GIT_PULL] },
[WorkflowEvent.BLOCKED]: "refining",
},
},
done: {
type: "terminal",
type: StateType.TERMINAL,
label: "Done",
color: "#5cb85c",
},
toDesign: {
type: "queue",
type: StateType.QUEUE,
role: "architect",
label: "To Design",
color: "#0075ca",
priority: 1,
on: { PICKUP: "designing" },
on: { [WorkflowEvent.PICKUP]: "designing" },
},
designing: {
type: "active",
type: StateType.ACTIVE,
role: "architect",
label: "Designing",
color: "#d4c5f9",
on: {
COMPLETE: "planning",
BLOCKED: "refining",
[WorkflowEvent.COMPLETE]: "planning",
[WorkflowEvent.BLOCKED]: "refining",
},
},
},
@@ -181,7 +237,7 @@ export function getLabelColors(workflow: WorkflowConfig): Record<string, string>
*/
export function getQueueLabels(workflow: WorkflowConfig, role: Role): string[] {
return Object.values(workflow.states)
.filter((s) => s.type === "queue" && s.role === role)
.filter((s) => s.type === StateType.QUEUE && s.role === role)
.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0))
.map((s) => s.label);
}
@@ -191,7 +247,7 @@ export function getQueueLabels(workflow: WorkflowConfig, role: Role): string[] {
*/
export function getAllQueueLabels(workflow: WorkflowConfig): string[] {
return Object.values(workflow.states)
.filter((s) => s.type === "queue")
.filter((s) => s.type === StateType.QUEUE)
.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0))
.map((s) => s.label);
}
@@ -201,7 +257,7 @@ export function getAllQueueLabels(workflow: WorkflowConfig): string[] {
*/
export function getActiveLabel(workflow: WorkflowConfig, role: Role): string {
const state = Object.values(workflow.states).find(
(s) => s.type === "active" && s.role === role,
(s) => s.type === StateType.ACTIVE && s.role === role,
);
if (!state) throw new Error(`No active state for role "${role}"`);
return state.label;
@@ -219,8 +275,8 @@ export function getRevertLabel(workflow: WorkflowConfig, role: Role): string {
// Find queue states that transition to this active state
for (const [, state] of Object.entries(workflow.states)) {
if (state.type !== "queue" || state.role !== role) continue;
const pickup = state.on?.PICKUP;
if (state.type !== StateType.QUEUE || state.role !== role) continue;
const pickup = state.on?.[WorkflowEvent.PICKUP];
if (pickup === activeStateKey) {
return state.label;
}
@@ -235,7 +291,7 @@ export function getRevertLabel(workflow: WorkflowConfig, role: Role): string {
*/
export function detectRoleFromLabel(workflow: WorkflowConfig, label: string): Role | null {
for (const state of Object.values(workflow.states)) {
if (state.label === label && state.type === "queue" && state.role) {
if (state.label === label && state.type === StateType.QUEUE && state.role) {
return state.role;
}
}
@@ -247,7 +303,7 @@ export function detectRoleFromLabel(workflow: WorkflowConfig, label: string): Ro
*/
export function isQueueLabel(workflow: WorkflowConfig, label: string): boolean {
return Object.values(workflow.states).some(
(s) => s.label === label && s.type === "queue",
(s) => s.label === label && s.type === StateType.QUEUE,
);
}
@@ -256,7 +312,7 @@ export function isQueueLabel(workflow: WorkflowConfig, label: string): boolean {
*/
export function isActiveLabel(workflow: WorkflowConfig, label: string): boolean {
return Object.values(workflow.states).some(
(s) => s.label === label && s.type === "active",
(s) => s.label === label && s.type === StateType.ACTIVE,
);
}
@@ -283,7 +339,8 @@ export function findStateKeyByLabel(workflow: WorkflowConfig, label: string): st
* Convention: "done" → COMPLETE, others → uppercase.
*/
function resultToEvent(result: string): string {
if (result === "done") return "COMPLETE";
if (result === "done") return WorkflowEvent.COMPLETE;
if (result === "review") return WorkflowEvent.REVIEW;
return result.toUpperCase();
}
@@ -320,10 +377,7 @@ export function getCompletionRule(
return {
from: activeLabel,
to: targetState.label,
gitPull: actions?.includes("gitPull"),
detectPr: actions?.includes("detectPr"),
closeIssue: actions?.includes("closeIssue"),
reopenIssue: actions?.includes("reopenIssue"),
actions: actions ?? [],
};
}
@@ -342,9 +396,10 @@ export function getNextStateDescription(
const targetState = findStateByLabel(workflow, rule.to);
if (!targetState) return "";
if (targetState.type === "terminal") return "Done!";
if (targetState.type === "hold") return "awaiting human decision";
if (targetState.type === "queue" && targetState.role) {
if (targetState.type === StateType.TERMINAL) return "Done!";
if (targetState.type === StateType.REVIEW) return "awaiting PR review";
if (targetState.type === StateType.HOLD) return "awaiting human decision";
if (targetState.type === StateType.QUEUE && targetState.role) {
return `${targetState.role.toUpperCase()} queue`;
}
@@ -357,6 +412,7 @@ export function getNextStateDescription(
*/
const RESULT_EMOJI: Record<string, string> = {
done: "✅",
review: "👀",
pass: "🎉",
fail: "❌",
refine: "🤔",

14
package-lock.json generated
View File

@@ -9,7 +9,9 @@
"version": "1.2.2",
"license": "MIT",
"dependencies": {
"yaml": "^2.8.2"
"cockatiel": "^3.2.1",
"yaml": "^2.8.2",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/node": "^25.2.3",
@@ -3769,6 +3771,15 @@
"node": ">= 8"
}
},
"node_modules/cockatiel": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/cockatiel/-/cockatiel-3.2.1.tgz",
"integrity": "sha512-gfrHV6ZPkquExvMh9IOkKsBzNDk6sDuZ6DdBGUBkvFnTCqCxzpuq48RySgP0AnaqQkw2zynOFj9yly6T1Q2G5Q==",
"license": "MIT",
"engines": {
"node": ">=16"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -8789,7 +8800,6 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

View File

@@ -55,6 +55,8 @@
"typescript": "^5.9.3"
},
"dependencies": {
"yaml": "^2.8.2"
"cockatiel": "^3.2.1",
"yaml": "^2.8.2",
"zod": "^4.3.6"
}
}