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:
253
lib/services/bootstrap.e2e.test.ts
Normal file
253
lib/services/bootstrap.e2e.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
747
lib/services/pipeline.e2e.test.ts
Normal file
747
lib/services/pipeline.e2e.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
88
lib/services/queue-scan.ts
Normal file
88
lib/services/queue-scan.ts
Normal 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;
|
||||
}
|
||||
@@ -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
98
lib/services/review.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user