Replaces single 'workMode' with two distinct settings: 1. Plugin-level 'projectExecution' (parallel | sequential) - parallel: each project can have active workers independently - sequential: only one project can have active workers at a time 2. Project-level 'roleExecution' (parallel | sequential) - parallel: DEV and QA can run simultaneously on same project - sequential: only one role (DEV or QA) active at a time per project Changes: - index.ts: rename workMode → projectExecution in config schema - projects.ts: add roleExecution field to Project type - heartbeat_tick: check both levels before picking up tasks - task_pickup: enforce roleExecution when picking up manually - project_register: accept roleExecution param (default: parallel) - devclaw_setup: accept projectExecution param All defaults remain 'parallel' for backward compatibility.
This commit is contained in:
4
index.ts
4
index.ts
@@ -30,10 +30,10 @@ const plugin = {
|
|||||||
qa: { type: "string", description: "QA engineer model" },
|
qa: { type: "string", description: "QA engineer model" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
workMode: {
|
projectExecution: {
|
||||||
type: "string",
|
type: "string",
|
||||||
enum: ["parallel", "sequential"],
|
enum: ["parallel", "sequential"],
|
||||||
description: "Work mode: parallel (each project independent) or sequential (1 DEV + 1 QA globally)",
|
description: "Plugin-level project execution: parallel (each project independent) or sequential (only one project active at a time)",
|
||||||
default: "parallel",
|
default: "parallel",
|
||||||
},
|
},
|
||||||
orchestratorDm: {
|
orchestratorDm: {
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ export type Project = {
|
|||||||
baseBranch: string;
|
baseBranch: string;
|
||||||
deployBranch: string;
|
deployBranch: string;
|
||||||
autoChain: boolean;
|
autoChain: boolean;
|
||||||
|
/** Project-level role execution: parallel (DEV+QA can run simultaneously) or sequential (only one role at a time). Default: parallel */
|
||||||
|
roleExecution?: "parallel" | "sequential";
|
||||||
maxDevWorkers?: number;
|
maxDevWorkers?: number;
|
||||||
maxQaWorkers?: number;
|
maxQaWorkers?: number;
|
||||||
dev: WorkerState;
|
dev: WorkerState;
|
||||||
|
|||||||
10
lib/setup.ts
10
lib/setup.ts
@@ -32,6 +32,8 @@ export type SetupOpts = {
|
|||||||
workspacePath?: string;
|
workspacePath?: string;
|
||||||
/** Model overrides per tier. Missing tiers use defaults. */
|
/** Model overrides per tier. Missing tiers use defaults. */
|
||||||
models?: Partial<Record<Tier, string>>;
|
models?: Partial<Record<Tier, string>>;
|
||||||
|
/** Plugin-level project execution mode: parallel or sequential. Default: parallel. */
|
||||||
|
projectExecution?: "parallel" | "sequential";
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SetupResult = {
|
export type SetupResult = {
|
||||||
@@ -110,7 +112,7 @@ export async function runSetup(opts: SetupOpts): Promise<SetupResult> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Write plugin config to openclaw.json (includes agentId in devClawAgentIds)
|
// Write plugin config to openclaw.json (includes agentId in devClawAgentIds)
|
||||||
await writePluginConfig(models, agentId);
|
await writePluginConfig(models, agentId, opts.projectExecution);
|
||||||
|
|
||||||
// --- Step 3: Workspace files ---
|
// --- Step 3: Workspace files ---
|
||||||
|
|
||||||
@@ -284,6 +286,7 @@ async function resolveWorkspacePath(agentId: string): Promise<string> {
|
|||||||
async function writePluginConfig(
|
async function writePluginConfig(
|
||||||
models: Record<Tier, string>,
|
models: Record<Tier, string>,
|
||||||
agentId?: string,
|
agentId?: string,
|
||||||
|
projectExecution?: "parallel" | "sequential",
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const configPath = path.join(
|
const configPath = path.join(
|
||||||
process.env.HOME ?? "/home/lauren",
|
process.env.HOME ?? "/home/lauren",
|
||||||
@@ -303,6 +306,11 @@ async function writePluginConfig(
|
|||||||
// Write models
|
// Write models
|
||||||
config.plugins.entries.devclaw.config.models = { ...models };
|
config.plugins.entries.devclaw.config.models = { ...models };
|
||||||
|
|
||||||
|
// Write projectExecution if specified
|
||||||
|
if (projectExecution) {
|
||||||
|
config.plugins.entries.devclaw.config.projectExecution = projectExecution;
|
||||||
|
}
|
||||||
|
|
||||||
// Configure subagent cleanup interval to 30 days (43200 minutes)
|
// Configure subagent cleanup interval to 30 days (43200 minutes)
|
||||||
// This keeps development sessions alive during active development
|
// This keeps development sessions alive during active development
|
||||||
if (!config.agents) config.agents = {};
|
if (!config.agents) config.agents = {};
|
||||||
|
|||||||
@@ -41,6 +41,11 @@ export function createSetupTool(api: OpenClawPluginApi) {
|
|||||||
qa: { type: "string", description: `QA engineer model (default: ${DEFAULT_MODELS.qa})` },
|
qa: { type: "string", description: `QA engineer model (default: ${DEFAULT_MODELS.qa})` },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
projectExecution: {
|
||||||
|
type: "string",
|
||||||
|
enum: ["parallel", "sequential"],
|
||||||
|
description: "Plugin-level project execution mode: parallel (each project independent) or sequential (work on one project at a time). Default: parallel.",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -49,6 +54,7 @@ export function createSetupTool(api: OpenClawPluginApi) {
|
|||||||
const channelBinding = params.channelBinding as "telegram" | "whatsapp" | undefined;
|
const channelBinding = params.channelBinding as "telegram" | "whatsapp" | undefined;
|
||||||
const migrateFrom = params.migrateFrom as string | undefined;
|
const migrateFrom = params.migrateFrom as string | undefined;
|
||||||
const modelsParam = params.models as Partial<Record<Tier, string>> | undefined;
|
const modelsParam = params.models as Partial<Record<Tier, string>> | undefined;
|
||||||
|
const projectExecution = params.projectExecution as "parallel" | "sequential" | undefined;
|
||||||
const workspaceDir = ctx.workspaceDir;
|
const workspaceDir = ctx.workspaceDir;
|
||||||
|
|
||||||
const result = await runSetup({
|
const result = await runSetup({
|
||||||
@@ -59,6 +65,7 @@ export function createSetupTool(api: OpenClawPluginApi) {
|
|||||||
agentId: newAgentName ? undefined : ctx.agentId,
|
agentId: newAgentName ? undefined : ctx.agentId,
|
||||||
workspacePath: newAgentName ? undefined : workspaceDir,
|
workspacePath: newAgentName ? undefined : workspaceDir,
|
||||||
models: modelsParam,
|
models: modelsParam,
|
||||||
|
projectExecution,
|
||||||
});
|
});
|
||||||
|
|
||||||
const lines = [
|
const lines = [
|
||||||
|
|||||||
@@ -6,7 +6,9 @@
|
|||||||
* 2. Loop over all projects
|
* 2. Loop over all projects
|
||||||
* 3. Check worker slots per project
|
* 3. Check worker slots per project
|
||||||
* 4. Pick up tasks by priority (To Improve > To Test > To Do)
|
* 4. Pick up tasks by priority (To Improve > To Test > To Do)
|
||||||
* 5. Respect work mode (parallel vs sequential)
|
* 5. Respect two-level work mode:
|
||||||
|
* - projectExecution (plugin-level): parallel/sequential for projects
|
||||||
|
* - roleExecution (project-level): parallel/sequential for roles within a project
|
||||||
* 6. Return summary of actions taken
|
* 6. Return summary of actions taken
|
||||||
*
|
*
|
||||||
* Context guard: Only allows from DM/cron context, blocks project groups.
|
* Context guard: Only allows from DM/cron context, blocks project groups.
|
||||||
@@ -43,7 +45,7 @@ const PRIORITY_ORDER: StateLabel[] = ["To Improve", "To Test", "To Do"];
|
|||||||
/** Tier labels that can appear on issues */
|
/** Tier labels that can appear on issues */
|
||||||
const TIER_LABELS: Tier[] = ["junior", "medior", "senior", "qa"];
|
const TIER_LABELS: Tier[] = ["junior", "medior", "senior", "qa"];
|
||||||
|
|
||||||
type WorkMode = "parallel" | "sequential";
|
type ExecutionMode = "parallel" | "sequential";
|
||||||
|
|
||||||
type PickupAction = {
|
type PickupAction = {
|
||||||
project: string;
|
project: string;
|
||||||
@@ -66,11 +68,11 @@ type HealthFix = {
|
|||||||
type TickResult = {
|
type TickResult = {
|
||||||
success: boolean;
|
success: boolean;
|
||||||
dryRun: boolean;
|
dryRun: boolean;
|
||||||
workMode: WorkMode;
|
projectExecution: ExecutionMode;
|
||||||
healthFixes: HealthFix[];
|
healthFixes: HealthFix[];
|
||||||
pickups: PickupAction[];
|
pickups: PickupAction[];
|
||||||
skipped: Array<{ project: string; reason: string }>;
|
skipped: Array<{ project: string; role?: "dev" | "qa"; reason: string }>;
|
||||||
globalState?: { activeDev: number; activeQa: number };
|
globalState?: { activeProjects: number; activeDev: number; activeQa: number };
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -228,7 +230,7 @@ export function createHeartbeatTickTool(api: OpenClawPluginApi) {
|
|||||||
return (ctx: ToolContext) => ({
|
return (ctx: ToolContext) => ({
|
||||||
name: "heartbeat_tick",
|
name: "heartbeat_tick",
|
||||||
label: "Heartbeat Tick",
|
label: "Heartbeat Tick",
|
||||||
description: `Automated task pickup across all projects. Runs session health checks, then picks up tasks by priority (To Improve > To Test > To Do). Respects work mode (parallel: each project independent, sequential: 1 DEV + 1 QA globally). Only works from DM/cron context, not project groups.`,
|
description: `Automated task pickup across all projects. Runs session health checks, then picks up tasks by priority (To Improve > To Test > To Do). Respects two-level work mode: plugin-level projectExecution (parallel/sequential for projects) and project-level roleExecution (parallel/sequential for roles within a project). Only works from DM/cron context, not project groups.`,
|
||||||
parameters: {
|
parameters: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
@@ -275,15 +277,15 @@ export function createHeartbeatTickTool(api: OpenClawPluginApi) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get work mode from plugin config
|
// Get plugin-level projectExecution mode from plugin config
|
||||||
const pluginConfig = api.pluginConfig as Record<string, unknown> | undefined;
|
const pluginConfig = api.pluginConfig as Record<string, unknown> | undefined;
|
||||||
const workMode: WorkMode =
|
const projectExecution: ExecutionMode =
|
||||||
(pluginConfig?.workMode as WorkMode) ?? "parallel";
|
(pluginConfig?.projectExecution as ExecutionMode) ?? "parallel";
|
||||||
|
|
||||||
const result: TickResult = {
|
const result: TickResult = {
|
||||||
success: true,
|
success: true,
|
||||||
dryRun,
|
dryRun,
|
||||||
workMode,
|
projectExecution,
|
||||||
healthFixes: [],
|
healthFixes: [],
|
||||||
pickups: [],
|
pickups: [],
|
||||||
skipped: [],
|
skipped: [],
|
||||||
@@ -303,6 +305,7 @@ export function createHeartbeatTickTool(api: OpenClawPluginApi) {
|
|||||||
// Track global worker counts for sequential mode
|
// Track global worker counts for sequential mode
|
||||||
let globalActiveDev = 0;
|
let globalActiveDev = 0;
|
||||||
let globalActiveQa = 0;
|
let globalActiveQa = 0;
|
||||||
|
let activeProjectCount = 0;
|
||||||
let pickupCount = 0;
|
let pickupCount = 0;
|
||||||
|
|
||||||
// First pass: count active workers and run health checks
|
// First pass: count active workers and run health checks
|
||||||
@@ -327,8 +330,11 @@ export function createHeartbeatTickTool(api: OpenClawPluginApi) {
|
|||||||
const refreshedData = await readProjects(workspaceDir);
|
const refreshedData = await readProjects(workspaceDir);
|
||||||
const refreshedProject = refreshedData.projects[groupId];
|
const refreshedProject = refreshedData.projects[groupId];
|
||||||
if (refreshedProject) {
|
if (refreshedProject) {
|
||||||
if (refreshedProject.dev.active) globalActiveDev++;
|
const devActive = refreshedProject.dev.active;
|
||||||
if (refreshedProject.qa.active) globalActiveQa++;
|
const qaActive = refreshedProject.qa.active;
|
||||||
|
if (devActive) globalActiveDev++;
|
||||||
|
if (qaActive) globalActiveQa++;
|
||||||
|
if (devActive || qaActive) activeProjectCount++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,12 +347,28 @@ export function createHeartbeatTickTool(api: OpenClawPluginApi) {
|
|||||||
|
|
||||||
const { provider } = createProvider({ repo: project.repo });
|
const { provider } = createProvider({ repo: project.repo });
|
||||||
|
|
||||||
|
// Get project-level roleExecution mode (default: parallel)
|
||||||
|
const roleExecution: ExecutionMode = project.roleExecution ?? "parallel";
|
||||||
|
|
||||||
|
// Check if this project has any active workers
|
||||||
|
const projectHasActiveWorker = project.dev.active || project.qa.active;
|
||||||
|
|
||||||
|
// Plugin-level projectExecution check: if sequential, only one project can have workers
|
||||||
|
if (projectExecution === "sequential" && !projectHasActiveWorker && activeProjectCount >= 1) {
|
||||||
|
result.skipped.push({
|
||||||
|
project: project.name,
|
||||||
|
reason: "Sequential projectExecution: another project has active workers",
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// Check each role
|
// Check each role
|
||||||
for (const role of ["dev", "qa"] as const) {
|
for (const role of ["dev", "qa"] as const) {
|
||||||
// Check max pickups limit
|
// Check max pickups limit
|
||||||
if (maxPickups !== undefined && pickupCount >= maxPickups) {
|
if (maxPickups !== undefined && pickupCount >= maxPickups) {
|
||||||
result.skipped.push({
|
result.skipped.push({
|
||||||
project: project.name,
|
project: project.name,
|
||||||
|
role,
|
||||||
reason: `Max pickups (${maxPickups}) reached`,
|
reason: `Max pickups (${maxPickups}) reached`,
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
@@ -357,6 +379,7 @@ export function createHeartbeatTickTool(api: OpenClawPluginApi) {
|
|||||||
if (worker.active) {
|
if (worker.active) {
|
||||||
result.skipped.push({
|
result.skipped.push({
|
||||||
project: project.name,
|
project: project.name,
|
||||||
|
role,
|
||||||
reason: `${role.toUpperCase()} already active (issue #${worker.issueId})`,
|
reason: `${role.toUpperCase()} already active (issue #${worker.issueId})`,
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
@@ -368,24 +391,21 @@ export function createHeartbeatTickTool(api: OpenClawPluginApi) {
|
|||||||
if (maxWorkers < 1) {
|
if (maxWorkers < 1) {
|
||||||
result.skipped.push({
|
result.skipped.push({
|
||||||
project: project.name,
|
project: project.name,
|
||||||
|
role,
|
||||||
reason: `${role.toUpperCase()} disabled (maxWorkers=0)`,
|
reason: `${role.toUpperCase()} disabled (maxWorkers=0)`,
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sequential mode: check global limits
|
// Project-level roleExecution check: if sequential, only one role can be active
|
||||||
if (workMode === "sequential") {
|
if (roleExecution === "sequential") {
|
||||||
if (role === "dev" && globalActiveDev >= 1) {
|
const otherRole = role === "dev" ? "qa" : "dev";
|
||||||
|
const otherWorker = getWorker(project, otherRole);
|
||||||
|
if (otherWorker.active) {
|
||||||
result.skipped.push({
|
result.skipped.push({
|
||||||
project: project.name,
|
project: project.name,
|
||||||
reason: "Sequential mode: DEV slot occupied globally",
|
role,
|
||||||
});
|
reason: `Sequential roleExecution: ${otherRole.toUpperCase()} already active`,
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (role === "qa" && globalActiveQa >= 1) {
|
|
||||||
result.skipped.push({
|
|
||||||
project: project.name,
|
|
||||||
reason: "Sequential mode: QA slot occupied globally",
|
|
||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -435,6 +455,7 @@ export function createHeartbeatTickTool(api: OpenClawPluginApi) {
|
|||||||
pickupCount++;
|
pickupCount++;
|
||||||
if (role === "dev") globalActiveDev++;
|
if (role === "dev") globalActiveDev++;
|
||||||
if (role === "qa") globalActiveQa++;
|
if (role === "qa") globalActiveQa++;
|
||||||
|
if (!projectHasActiveWorker) activeProjectCount++;
|
||||||
} else {
|
} else {
|
||||||
// Actually dispatch
|
// Actually dispatch
|
||||||
try {
|
try {
|
||||||
@@ -469,9 +490,11 @@ export function createHeartbeatTickTool(api: OpenClawPluginApi) {
|
|||||||
pickupCount++;
|
pickupCount++;
|
||||||
if (role === "dev") globalActiveDev++;
|
if (role === "dev") globalActiveDev++;
|
||||||
if (role === "qa") globalActiveQa++;
|
if (role === "qa") globalActiveQa++;
|
||||||
|
if (!projectHasActiveWorker) activeProjectCount++;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
result.skipped.push({
|
result.skipped.push({
|
||||||
project: project.name,
|
project: project.name,
|
||||||
|
role,
|
||||||
reason: `Dispatch failed for #${issue.iid}: ${(err as Error).message}`,
|
reason: `Dispatch failed for #${issue.iid}: ${(err as Error).message}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -479,18 +502,17 @@ export function createHeartbeatTickTool(api: OpenClawPluginApi) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add global state for sequential mode visibility
|
// Add global state for visibility
|
||||||
if (workMode === "sequential") {
|
|
||||||
result.globalState = {
|
result.globalState = {
|
||||||
|
activeProjects: activeProjectCount,
|
||||||
activeDev: globalActiveDev,
|
activeDev: globalActiveDev,
|
||||||
activeQa: globalActiveQa,
|
activeQa: globalActiveQa,
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
// Audit log
|
// Audit log
|
||||||
await auditLog(workspaceDir, "heartbeat_tick", {
|
await auditLog(workspaceDir, "heartbeat_tick", {
|
||||||
dryRun,
|
dryRun,
|
||||||
workMode,
|
projectExecution,
|
||||||
projectsScanned: projectEntries.length,
|
projectsScanned: projectEntries.length,
|
||||||
healthFixes: result.healthFixes.length,
|
healthFixes: result.healthFixes.length,
|
||||||
pickups: result.pickups.length,
|
pickups: result.pickups.length,
|
||||||
|
|||||||
@@ -106,6 +106,11 @@ export function createProjectRegisterTool(api: OpenClawPluginApi) {
|
|||||||
type: "string",
|
type: "string",
|
||||||
description: "Deployment URL for the project",
|
description: "Deployment URL for the project",
|
||||||
},
|
},
|
||||||
|
roleExecution: {
|
||||||
|
type: "string",
|
||||||
|
enum: ["parallel", "sequential"],
|
||||||
|
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.",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -117,6 +122,7 @@ export function createProjectRegisterTool(api: OpenClawPluginApi) {
|
|||||||
const baseBranch = params.baseBranch as string;
|
const baseBranch = params.baseBranch as string;
|
||||||
const deployBranch = (params.deployBranch as string) ?? baseBranch;
|
const deployBranch = (params.deployBranch as string) ?? baseBranch;
|
||||||
const deployUrl = (params.deployUrl as string) ?? "";
|
const deployUrl = (params.deployUrl as string) ?? "";
|
||||||
|
const roleExecution = (params.roleExecution as "parallel" | "sequential") ?? "parallel";
|
||||||
const workspaceDir = ctx.workspaceDir;
|
const workspaceDir = ctx.workspaceDir;
|
||||||
|
|
||||||
if (!workspaceDir) {
|
if (!workspaceDir) {
|
||||||
@@ -199,6 +205,7 @@ export function createProjectRegisterTool(api: OpenClawPluginApi) {
|
|||||||
baseBranch,
|
baseBranch,
|
||||||
deployBranch,
|
deployBranch,
|
||||||
autoChain: false,
|
autoChain: false,
|
||||||
|
roleExecution,
|
||||||
dev: emptyWorkerState([...DEV_TIERS]),
|
dev: emptyWorkerState([...DEV_TIERS]),
|
||||||
qa: emptyWorkerState([...QA_TIERS]),
|
qa: emptyWorkerState([...QA_TIERS]),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -229,6 +229,18 @@ export function createTaskPickupTool(api: OpenClawPluginApi) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 6b. Check project-level roleExecution
|
||||||
|
const roleExecution = project.roleExecution ?? "parallel";
|
||||||
|
if (roleExecution === "sequential") {
|
||||||
|
const otherRole = role === "dev" ? "qa" : "dev";
|
||||||
|
const otherWorker = getWorker(project, otherRole);
|
||||||
|
if (otherWorker.active) {
|
||||||
|
throw new Error(
|
||||||
|
`Project "${project.name}" has sequential roleExecution: ${otherRole.toUpperCase()} worker is active (issue: ${otherWorker.issueId}). Wait for it to complete first.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 7. Select model (priority: param > tier label > heuristic)
|
// 7. Select model (priority: param > tier label > heuristic)
|
||||||
const targetLabel: StateLabel = role === "dev" ? "Doing" : "Testing";
|
const targetLabel: StateLabel = role === "dev" ? "Doing" : "Testing";
|
||||||
let modelAlias: string;
|
let modelAlias: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user