Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ roles:

### Workflow States

The workflow section defines the state machine for issue lifecycle — states, transitions, review policy, and the optional test phase.
The workflow section defines the state machine for issue lifecycle — states, transitions, review policy, the optional test phase, and optional delivery policies for promotion and acceptance.

See **[Workflow Reference](WORKFLOW.md)** for the full state machine documentation, including state types, built-in actions, review policy options, and how to enable the test phase.

Expand Down
2 changes: 1 addition & 1 deletion docs/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ Planning → To Do → Doing → To Review → [PR approved → auto-merge] →
To Research → Researching → Planning (architect posts findings)
```

States have types (`queue`, `active`, `hold`, `terminal`), transitions with actions (`gitPull`, `detectPr`, `mergePr`, `closeIssue`, `reopenIssue`), and review checks (`prMerged`, `prApproved`). The test phase (toTest, testing) can be enabled via `workflow.yaml` — see [Workflow](WORKFLOW.md#test-phase-optional).
States have types (`queue`, `active`, `hold`, `terminal`), transitions with actions (`gitPull`, `detectPr`, `mergePr`, `closeIssue`, `reopenIssue`), and review checks (`prMerged`, `prApproved`). The test phase (toTest, testing) and delivery phases (toPromote/promoting, toAccept/accepting) can be enabled or skipped via `workflow.yaml` — see [Workflow](WORKFLOW.md#test-phase-optional).

### Three-Layer Configuration

Expand Down
4 changes: 2 additions & 2 deletions docs/WORKFLOW.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# DevClaw — Workflow Reference

The issue lifecycle in DevClaw is a configurable state machine defined in `workflow.yaml`. This document covers the default pipeline, all state types, review policies, and the optional test phase.
The issue lifecycle in DevClaw is a configurable state machine defined in `workflow.yaml`. This document covers the default pipeline, all state types, review policies, the optional test phase, and the optional delivery phases for candidate promotion and acceptance.

For config file format and location, see [Configuration](CONFIGURATION.md).

Expand All @@ -12,7 +12,7 @@ For config file format and location, see [Configuration](CONFIGURATION.md).
Planning → To Do → Doing → To Review → PR approved → Done (auto-merge + close)
```

Human review, no test phase. Approved PRs are auto-merged and the issue is closed.
Human review, no test phase, and delivery phases skipped by default. Approved PRs are auto-merged, test is auto-skipped, promotion is auto-skipped, acceptance is auto-skipped, and the issue is closed.

```mermaid
stateDiagram-v2
Expand Down
10 changes: 10 additions & 0 deletions lib/config/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,16 @@ function resolve(config: DevClawConfig): ResolvedConfig {
initial: config.workflow?.initial ?? DEFAULT_WORKFLOW.initial,
reviewPolicy: config.workflow?.reviewPolicy ?? DEFAULT_WORKFLOW.reviewPolicy,
testPolicy: config.workflow?.testPolicy ?? DEFAULT_WORKFLOW.testPolicy,
delivery: {
promotion: {
...DEFAULT_WORKFLOW.delivery?.promotion,
...config.workflow?.delivery?.promotion,
},
acceptance: {
...DEFAULT_WORKFLOW.delivery?.acceptance,
...config.workflow?.delivery?.acceptance,
},
},
roleExecution: config.workflow?.roleExecution ?? DEFAULT_WORKFLOW.roleExecution,
states: { ...DEFAULT_WORKFLOW.states, ...config.workflow?.states },
};
Expand Down
12 changes: 12 additions & 0 deletions lib/config/merge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,18 @@ export function mergeConfig(
initial: overlay.workflow?.initial ?? base.workflow?.initial,
reviewPolicy: overlay.workflow?.reviewPolicy ?? base.workflow?.reviewPolicy,
testPolicy: overlay.workflow?.testPolicy ?? base.workflow?.testPolicy,
delivery: base.workflow?.delivery || overlay.workflow?.delivery
? {
promotion: {
...base.workflow?.delivery?.promotion,
...overlay.workflow?.delivery?.promotion,
},
acceptance: {
...base.workflow?.delivery?.acceptance,
...overlay.workflow?.delivery?.acceptance,
},
}
: undefined,
roleExecution: overlay.workflow?.roleExecution ?? base.workflow?.roleExecution,
maxWorkersPerLevel: overlay.workflow?.maxWorkersPerLevel ?? base.workflow?.maxWorkersPerLevel,
states: {
Expand Down
28 changes: 28 additions & 0 deletions lib/config/schema.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { describe, it } from "node:test";
import assert from "node:assert";
import { validateWorkflowIntegrity } from "./schema.js";
import { DEFAULT_WORKFLOW } from "../workflow/index.js";

describe("validateWorkflowIntegrity delivery role validation", () => {
it("rejects promotion states that are not reviewer-owned", () => {
const workflow = structuredClone(DEFAULT_WORKFLOW);
workflow.delivery!.promotion!.queueState = "toTest";
workflow.delivery!.promotion!.activeState = "testing";

const errors = validateWorkflowIntegrity(workflow);

assert.ok(errors.includes("workflow.delivery.promotion.queueState must reference a reviewer-owned state"));
assert.ok(errors.includes("workflow.delivery.promotion.activeState must reference a reviewer-owned state"));
});

it("rejects acceptance states that are not tester-owned", () => {
const workflow = structuredClone(DEFAULT_WORKFLOW);
workflow.delivery!.acceptance!.queueState = "toReview";
workflow.delivery!.acceptance!.activeState = "promoting";

const errors = validateWorkflowIntegrity(workflow);

assert.ok(errors.includes("workflow.delivery.acceptance.queueState must reference a tester-owned state"));
assert.ok(errors.includes("workflow.delivery.acceptance.activeState must reference a tester-owned state"));
});
});
34 changes: 33 additions & 1 deletion lib/config/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,20 @@ const StateConfigSchema = z.object({
on: z.record(z.string(), TransitionTargetSchema).optional(),
});

const DeliveryPhaseSchema = z.object({
policy: z.enum(["human", "agent", "skip"]).optional(),
queueState: z.string().optional(),
activeState: z.string().optional(),
}).optional();

const WorkflowConfigSchema = z.object({
initial: z.string(),
reviewPolicy: z.enum(["human", "agent", "skip"]).optional(),
testPolicy: z.enum(["skip", "agent"]).optional(),
delivery: z.object({
promotion: DeliveryPhaseSchema,
acceptance: DeliveryPhaseSchema,
}).optional(),
roleExecution: z.enum(["parallel", "sequential"]).optional(),
maxWorkersPerLevel: z.number().int().positive().optional(),
states: z.record(z.string(), StateConfigSchema),
Expand Down Expand Up @@ -95,7 +105,7 @@ export function validateConfig(raw: unknown): void {
* - Terminal states have no outgoing transitions
*/
export function validateWorkflowIntegrity(
workflow: { initial: string; states: Record<string, { type: string; role?: string; on?: Record<string, unknown> }> },
workflow: { initial: string; delivery?: { promotion?: { queueState?: string; activeState?: string }; acceptance?: { queueState?: string; activeState?: string } }; states: Record<string, { type: string; role?: string; on?: Record<string, unknown> }> },
): string[] {
const errors: string[] = [];
const stateKeys = new Set(Object.keys(workflow.states));
Expand All @@ -104,6 +114,28 @@ export function validateWorkflowIntegrity(
errors.push(`Initial state "${workflow.initial}" does not exist in states`);
}

const validateDeliveryRef = (phase: "promotion" | "acceptance", stateKind: "queueState" | "activeState", value?: string) => {
if (!value) return;
if (!stateKeys.has(value)) {
errors.push(`workflow.delivery.${phase}.${stateKind} references non-existent state "${value}"`);
return;
}
const state = workflow.states[value];
const expectedType = stateKind === "queueState" ? StateType.QUEUE : StateType.ACTIVE;
const expectedRole = phase === "promotion" ? "reviewer" : "tester";
if (state?.type !== expectedType) {
errors.push(`workflow.delivery.${phase}.${stateKind} must reference a ${expectedType} state`);
}
if (state?.role !== expectedRole) {
errors.push(`workflow.delivery.${phase}.${stateKind} must reference a ${expectedRole}-owned state`);
}
};

validateDeliveryRef("promotion", "queueState", workflow.delivery?.promotion?.queueState);
validateDeliveryRef("promotion", "activeState", workflow.delivery?.promotion?.activeState);
validateDeliveryRef("acceptance", "queueState", workflow.delivery?.acceptance?.queueState);
validateDeliveryRef("acceptance", "activeState", workflow.delivery?.acceptance?.activeState);

for (const [key, state] of Object.entries(workflow.states)) {
if (state.type === StateType.QUEUE && !state.role) {
errors.push(`Queue state "${key}" must have a role assigned`);
Expand Down
22 changes: 21 additions & 1 deletion lib/dispatch/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import {
import { resolveModel } from "../roles/index.js";
import { notify, getNotificationConfig } from "./notify.js";
import { loadConfig, type ResolvedRoleConfig } from "../config/index.js";
import { ReviewPolicy, TestPolicy, resolveReviewRouting, resolveTestRouting, resolveNotifyChannel, isFeedbackState, hasReviewCheck, producesReviewableWork, hasTestPhase, detectOwner, getOwnerLabel, OWNER_LABEL_COLOR, getRoleLabelColor, STEP_ROUTING_COLOR, getStateLabels } from "../workflow/index.js";
import { ReviewPolicy, TestPolicy, DeliveryPolicy, resolveReviewRouting, resolveTestRouting, resolveDeliveryRouting, resolveNotifyChannel, isFeedbackState, hasReviewCheck, producesReviewableWork, hasTestPhase, hasDeliveryPhase, detectOwner, getOwnerLabel, OWNER_LABEL_COLOR, getRoleLabelColor, STEP_ROUTING_COLOR, getStateLabels } from "../workflow/index.js";
import { fetchPrFeedback, fetchPrContext, type PrFeedback, type PrContext } from "./pr-context.js";
import { formatAttachmentsForTask } from "./attachments.js";
import { loadRoleInstructions } from "./bootstrap-hook.js";
Expand Down Expand Up @@ -253,6 +253,26 @@ export async function dispatchTask(
await provider.addLabel(issueId, testLabel);
}

if (hasDeliveryPhase(workflow, "promotion")) {
const promotionPolicy = workflow.delivery?.promotion?.policy ?? DeliveryPolicy.SKIP;
const promotionLabel = resolveDeliveryRouting(promotionPolicy, "promotion");
const oldPromotionRouting = issue.labels.filter((l) => l.startsWith("promotion:"));
const safePromotionRouting = filterNonStateLabels(oldPromotionRouting, stateLabels);
if (safePromotionRouting.length > 0) await provider.removeLabels(issueId, safePromotionRouting);
await provider.ensureLabel(promotionLabel, STEP_ROUTING_COLOR);
await provider.addLabel(issueId, promotionLabel);
}

if (hasDeliveryPhase(workflow, "acceptance")) {
const acceptancePolicy = workflow.delivery?.acceptance?.policy ?? DeliveryPolicy.SKIP;
const acceptanceLabel = resolveDeliveryRouting(acceptancePolicy, "acceptance");
const oldAcceptanceRouting = issue.labels.filter((l) => l.startsWith("acceptance:"));
const safeAcceptanceRouting = filterNonStateLabels(oldAcceptanceRouting, stateLabels);
if (safeAcceptanceRouting.length > 0) await provider.removeLabels(issueId, safeAcceptanceRouting);
await provider.ensureLabel(acceptanceLabel, STEP_ROUTING_COLOR);
await provider.addLabel(issueId, acceptanceLabel);
}

// Apply owner label if issue is unclaimed (auto-claim on pickup)
if (opts.instanceName && !detectOwner(issue.labels)) {
const ownerLabel = getOwnerLabel(opts.instanceName);
Expand Down
160 changes: 160 additions & 0 deletions lib/services/delivery-phases.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import { afterEach, describe, it } from "node:test";
import assert from "node:assert";
import { createTestHarness, type TestHarness } from "../testing/index.js";
import { projectTick } from "./tick.js";
import { deliveryPass } from "./heartbeat/delivery.js";
import { DEFAULT_WORKFLOW, getCompletionRule, renderCandidateDecision, renderCandidateRecord } from "../workflow/index.js";

describe("delivery phase routing", () => {
let h: TestHarness;

afterEach(async () => {
if (h) await h.cleanup();
});

it("derives reviewer/tester completion rules from delivery active states", () => {
const promoteRule = getCompletionRule(DEFAULT_WORKFLOW, "reviewer", "approve", "Promoting");
const acceptRule = getCompletionRule(DEFAULT_WORKFLOW, "tester", "pass", "Accepting");

assert.deepStrictEqual(promoteRule, {
from: "Promoting",
to: "To Accept",
actions: [],
});
assert.deepStrictEqual(acceptRule, {
from: "Accepting",
to: "Done",
actions: ["closeIssue"],
});
});

it("dispatches delivery queues into their matching active states", async () => {
h = await createTestHarness({
workers: {
reviewer: { active: false, issueId: null, sessionKey: null },
tester: { active: false, issueId: null, sessionKey: null },
},
});

h.provider.seedIssue({ iid: 42, title: "Promote candidate", labels: ["To Promote", "promotion:agent"] });
h.provider.seedIssue({ iid: 43, title: "Accept candidate", labels: ["To Accept", "acceptance:agent"] });

const reviewerTick = await projectTick({
workspaceDir: h.workspaceDir,
projectSlug: h.project.slug,
provider: h.provider,
targetRole: "reviewer",
runCommand: h.runCommand,
});
const testerTick = await projectTick({
workspaceDir: h.workspaceDir,
projectSlug: h.project.slug,
provider: h.provider,
targetRole: "tester",
runCommand: h.runCommand,
});

assert.strictEqual(reviewerTick.pickups.length, 1);
assert.strictEqual(testerTick.pickups.length, 1);

const transitions = h.provider.callsTo("transitionLabel");
assert.deepStrictEqual(transitions.map((call) => call.args), [
{ issueId: 42, from: "To Promote", to: "Promoting" },
{ issueId: 43, from: "To Accept", to: "Accepting" },
]);
});

it("does not auto-promote human-routed delivery without an explicit candidate record", async () => {
h = await createTestHarness();
h.provider.seedIssue({ iid: 44, title: "Human promote", labels: ["To Promote", "promotion:human"] });

const transitions = await deliveryPass({
workspaceDir: h.workspaceDir,
projectName: h.project.slug,
workflow: h.workflow,
provider: h.provider,
repoPath: h.project.repo,
runCommand: h.runCommand,
});

assert.strictEqual(transitions, 0);
assert.deepStrictEqual(h.provider.callsTo("transitionLabel"), []);
});

it("advances human-routed promotion only after an active candidate record exists", async () => {
h = await createTestHarness();
h.provider.seedIssue({ iid: 45, title: "Human promote", labels: ["To Promote", "promotion:human"] });
await h.provider.addComment(45, renderCandidateRecord({
issueId: 45,
candidateId: "cand-45",
commitSha: "abc123",
targetHint: "candidate",
status: "active",
promotedAt: new Date().toISOString(),
}));

const transitions = await deliveryPass({
workspaceDir: h.workspaceDir,
projectName: h.project.slug,
workflow: h.workflow,
provider: h.provider,
repoPath: h.project.repo,
runCommand: h.runCommand,
});

assert.strictEqual(transitions, 1);
assert.deepStrictEqual(h.provider.callsTo("transitionLabel").at(-1)?.args, {
issueId: 45,
from: "To Promote",
to: "To Accept",
});
});

it("advances human-routed acceptance only after a human acceptance decision is recorded", async () => {
h = await createTestHarness();
h.provider.seedIssue({ iid: 46, title: "Human accept", labels: ["To Accept", "acceptance:human"] });
await h.provider.addComment(46, renderCandidateRecord({
issueId: 46,
candidateId: "cand-46",
commitSha: "def456",
targetHint: "candidate",
status: "active",
promotedAt: new Date().toISOString(),
}));

const before = await deliveryPass({
workspaceDir: h.workspaceDir,
projectName: h.project.slug,
workflow: h.workflow,
provider: h.provider,
repoPath: h.project.repo,
runCommand: h.runCommand,
});

assert.strictEqual(before, 0);

await h.provider.addComment(46, renderCandidateDecision({
issueId: 46,
candidateId: "cand-46",
status: "accepted",
decidedAt: new Date().toISOString(),
reason: "Operator accepted promoted candidate",
}));

const after = await deliveryPass({
workspaceDir: h.workspaceDir,
projectName: h.project.slug,
workflow: h.workflow,
provider: h.provider,
repoPath: h.project.repo,
runCommand: h.runCommand,
});

assert.strictEqual(after, 1);
assert.deepStrictEqual(h.provider.callsTo("transitionLabel").at(-1)?.args, {
issueId: 46,
from: "To Accept",
to: "Done",
});
});
});
Loading