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" },
|
||||
},
|
||||
},
|
||||
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: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
10
lib/setup.ts
10
lib/setup.ts
@@ -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 = {};
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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") {
|
||||
result.globalState = {
|
||||
activeDev: globalActiveDev,
|
||||
activeQa: globalActiveQa,
|
||||
};
|
||||
}
|
||||
// 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,
|
||||
|
||||
@@ -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]),
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user