feat: add two-level work mode (projectExecution + roleExecution) (#15) (#18)

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:
Lauren ten Hoor
2026-02-10 00:50:52 +08:00
committed by GitHub
parent d40aa41b16
commit aae6c2ee78
7 changed files with 92 additions and 34 deletions

View File

@@ -30,10 +30,10 @@ const plugin = {
qa: { type: "string", description: "QA engineer model" },
},
},
workMode: {
projectExecution: {
type: "string",
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",
},
orchestratorDm: {

View File

@@ -23,6 +23,8 @@ export type Project = {
baseBranch: string;
deployBranch: string;
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;
maxQaWorkers?: number;
dev: WorkerState;

View File

@@ -32,6 +32,8 @@ export type SetupOpts = {
workspacePath?: string;
/** Model overrides per tier. Missing tiers use defaults. */
models?: Partial<Record<Tier, string>>;
/** Plugin-level project execution mode: parallel or sequential. Default: parallel. */
projectExecution?: "parallel" | "sequential";
};
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)
await writePluginConfig(models, agentId);
await writePluginConfig(models, agentId, opts.projectExecution);
// --- Step 3: Workspace files ---
@@ -284,6 +286,7 @@ async function resolveWorkspacePath(agentId: string): Promise<string> {
async function writePluginConfig(
models: Record<Tier, string>,
agentId?: string,
projectExecution?: "parallel" | "sequential",
): Promise<void> {
const configPath = path.join(
process.env.HOME ?? "/home/lauren",
@@ -303,6 +306,11 @@ async function writePluginConfig(
// Write 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)
// This keeps development sessions alive during active development
if (!config.agents) config.agents = {};

View File

@@ -41,6 +41,11 @@ export function createSetupTool(api: OpenClawPluginApi) {
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 migrateFrom = params.migrateFrom as 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 result = await runSetup({
@@ -59,6 +65,7 @@ export function createSetupTool(api: OpenClawPluginApi) {
agentId: newAgentName ? undefined : ctx.agentId,
workspacePath: newAgentName ? undefined : workspaceDir,
models: modelsParam,
projectExecution,
});
const lines = [

View File

@@ -6,7 +6,9 @@
* 2. Loop over all projects
* 3. Check worker slots per project
* 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
*
* 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 */
const TIER_LABELS: Tier[] = ["junior", "medior", "senior", "qa"];
type WorkMode = "parallel" | "sequential";
type ExecutionMode = "parallel" | "sequential";
type PickupAction = {
project: string;
@@ -66,11 +68,11 @@ type HealthFix = {
type TickResult = {
success: boolean;
dryRun: boolean;
workMode: WorkMode;
projectExecution: ExecutionMode;
healthFixes: HealthFix[];
pickups: PickupAction[];
skipped: Array<{ project: string; reason: string }>;
globalState?: { activeDev: number; activeQa: number };
skipped: Array<{ project: string; role?: "dev" | "qa"; reason: string }>;
globalState?: { activeProjects: number; activeDev: number; activeQa: number };
};
/**
@@ -228,7 +230,7 @@ export function createHeartbeatTickTool(api: OpenClawPluginApi) {
return (ctx: ToolContext) => ({
name: "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: {
type: "object",
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 workMode: WorkMode =
(pluginConfig?.workMode as WorkMode) ?? "parallel";
const projectExecution: ExecutionMode =
(pluginConfig?.projectExecution as ExecutionMode) ?? "parallel";
const result: TickResult = {
success: true,
dryRun,
workMode,
projectExecution,
healthFixes: [],
pickups: [],
skipped: [],
@@ -303,6 +305,7 @@ export function createHeartbeatTickTool(api: OpenClawPluginApi) {
// Track global worker counts for sequential mode
let globalActiveDev = 0;
let globalActiveQa = 0;
let activeProjectCount = 0;
let pickupCount = 0;
// First pass: count active workers and run health checks
@@ -327,8 +330,11 @@ export function createHeartbeatTickTool(api: OpenClawPluginApi) {
const refreshedData = await readProjects(workspaceDir);
const refreshedProject = refreshedData.projects[groupId];
if (refreshedProject) {
if (refreshedProject.dev.active) globalActiveDev++;
if (refreshedProject.qa.active) globalActiveQa++;
const devActive = refreshedProject.dev.active;
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 });
// 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
for (const role of ["dev", "qa"] as const) {
// Check max pickups limit
if (maxPickups !== undefined && pickupCount >= maxPickups) {
result.skipped.push({
project: project.name,
role,
reason: `Max pickups (${maxPickups}) reached`,
});
continue;
@@ -357,6 +379,7 @@ export function createHeartbeatTickTool(api: OpenClawPluginApi) {
if (worker.active) {
result.skipped.push({
project: project.name,
role,
reason: `${role.toUpperCase()} already active (issue #${worker.issueId})`,
});
continue;
@@ -368,24 +391,21 @@ export function createHeartbeatTickTool(api: OpenClawPluginApi) {
if (maxWorkers < 1) {
result.skipped.push({
project: project.name,
role,
reason: `${role.toUpperCase()} disabled (maxWorkers=0)`,
});
continue;
}
// Sequential mode: check global limits
if (workMode === "sequential") {
if (role === "dev" && globalActiveDev >= 1) {
// Project-level roleExecution check: if sequential, only one role can be active
if (roleExecution === "sequential") {
const otherRole = role === "dev" ? "qa" : "dev";
const otherWorker = getWorker(project, otherRole);
if (otherWorker.active) {
result.skipped.push({
project: project.name,
reason: "Sequential mode: DEV slot occupied globally",
});
continue;
}
if (role === "qa" && globalActiveQa >= 1) {
result.skipped.push({
project: project.name,
reason: "Sequential mode: QA slot occupied globally",
role,
reason: `Sequential roleExecution: ${otherRole.toUpperCase()} already active`,
});
continue;
}
@@ -435,6 +455,7 @@ export function createHeartbeatTickTool(api: OpenClawPluginApi) {
pickupCount++;
if (role === "dev") globalActiveDev++;
if (role === "qa") globalActiveQa++;
if (!projectHasActiveWorker) activeProjectCount++;
} else {
// Actually dispatch
try {
@@ -469,9 +490,11 @@ export function createHeartbeatTickTool(api: OpenClawPluginApi) {
pickupCount++;
if (role === "dev") globalActiveDev++;
if (role === "qa") globalActiveQa++;
if (!projectHasActiveWorker) activeProjectCount++;
} catch (err) {
result.skipped.push({
project: project.name,
role,
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
if (workMode === "sequential") {
// Add global state for visibility
result.globalState = {
activeProjects: activeProjectCount,
activeDev: globalActiveDev,
activeQa: globalActiveQa,
};
}
// Audit log
await auditLog(workspaceDir, "heartbeat_tick", {
dryRun,
workMode,
projectExecution,
projectsScanned: projectEntries.length,
healthFixes: result.healthFixes.length,
pickups: result.pickups.length,

View File

@@ -106,6 +106,11 @@ export function createProjectRegisterTool(api: OpenClawPluginApi) {
type: "string",
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 deployBranch = (params.deployBranch as string) ?? baseBranch;
const deployUrl = (params.deployUrl as string) ?? "";
const roleExecution = (params.roleExecution as "parallel" | "sequential") ?? "parallel";
const workspaceDir = ctx.workspaceDir;
if (!workspaceDir) {
@@ -199,6 +205,7 @@ export function createProjectRegisterTool(api: OpenClawPluginApi) {
baseBranch,
deployBranch,
autoChain: false,
roleExecution,
dev: emptyWorkerState([...DEV_TIERS]),
qa: emptyWorkerState([...QA_TIERS]),
};

View File

@@ -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)
const targetLabel: StateLabel = role === "dev" ? "Doing" : "Testing";
let modelAlias: string;