From e9d256c06fabb2d3d78f1495590e5b8a87968e74 Mon Sep 17 00:00:00 2001 From: fujiwaranosai850 Date: Thu, 7 May 2026 00:16:17 +0000 Subject: [PATCH 01/30] feat: add delivery workflow phases --- docs/CONFIGURATION.md | 2 +- docs/ROADMAP.md | 2 +- docs/WORKFLOW.md | 4 +- lib/config/loader.ts | 10 +++ lib/config/merge.ts | 12 +++ lib/config/schema.ts | 30 ++++++- lib/dispatch/index.ts | 22 ++++- lib/services/heartbeat/delivery.ts | 89 ++++++++++++++++++++ lib/services/heartbeat/passes.ts | 17 +++- lib/services/heartbeat/tick-runner.ts | 10 +++ lib/services/pipeline.ts | 27 +++++++ lib/services/tick.ts | 53 ++++++------ lib/tools/admin/project-register.ts | 6 +- lib/tools/admin/project-status.ts | 4 + lib/tools/admin/workflow-guide.ts | 45 ++++++++++- lib/workflow/candidate-provenance.ts | 112 ++++++++++++++++++++++++++ lib/workflow/defaults.ts | 58 +++++++++++++ lib/workflow/index.ts | 1 + lib/workflow/labels.ts | 18 ++++- lib/workflow/queries.ts | 28 +++++++ lib/workflow/types.ts | 29 +++++++ 21 files changed, 540 insertions(+), 39 deletions(-) create mode 100644 lib/services/heartbeat/delivery.ts create mode 100644 lib/workflow/candidate-provenance.ts diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 05468127..5380a086 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -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. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index a750832f..f27331a9 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -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 diff --git a/docs/WORKFLOW.md b/docs/WORKFLOW.md index 09ebd566..f63e4fcc 100644 --- a/docs/WORKFLOW.md +++ b/docs/WORKFLOW.md @@ -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). @@ -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 diff --git a/lib/config/loader.ts b/lib/config/loader.ts index 9bcc4038..79aa0506 100644 --- a/lib/config/loader.ts +++ b/lib/config/loader.ts @@ -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 }, }; diff --git a/lib/config/merge.ts b/lib/config/merge.ts index e33a8393..00957053 100644 --- a/lib/config/merge.ts +++ b/lib/config/merge.ts @@ -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: { diff --git a/lib/config/schema.ts b/lib/config/schema.ts index e18f47a8..9f263049 100644 --- a/lib/config/schema.ts +++ b/lib/config/schema.ts @@ -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), @@ -95,7 +105,7 @@ export function validateConfig(raw: unknown): void { * - Terminal states have no outgoing transitions */ export function validateWorkflowIntegrity( - workflow: { initial: string; states: Record }> }, + workflow: { initial: string; delivery?: { promotion?: { queueState?: string; activeState?: string }; acceptance?: { queueState?: string; activeState?: string } }; states: Record }> }, ): string[] { const errors: string[] = []; const stateKeys = new Set(Object.keys(workflow.states)); @@ -104,6 +114,24 @@ 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; + if (state?.type !== expectedType) { + errors.push(`workflow.delivery.${phase}.${stateKind} must reference a ${expectedType} 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`); diff --git a/lib/dispatch/index.ts b/lib/dispatch/index.ts index d0ca0446..eb2c2d86 100644 --- a/lib/dispatch/index.ts +++ b/lib/dispatch/index.ts @@ -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"; @@ -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); diff --git a/lib/services/heartbeat/delivery.ts b/lib/services/heartbeat/delivery.ts new file mode 100644 index 00000000..e78912d7 --- /dev/null +++ b/lib/services/heartbeat/delivery.ts @@ -0,0 +1,89 @@ +import type { IssueProvider } from "../../providers/provider.js"; +import { + Action, + StateType, + WorkflowEvent, + getCurrentCandidate, + markCandidateStatus, + type WorkflowConfig, + type StateConfig, +} from "../../workflow/index.js"; +import { detectStepRouting } from "../queue-scan.js"; +import { log as auditLog } from "../../audit.js"; + +export async function deliveryPass(opts: { + workspaceDir: string; + projectName: string; + workflow: WorkflowConfig; + provider: IssueProvider; +}): Promise { + const { workspaceDir, projectName, workflow, provider } = opts; + let transitions = 0; + + for (const [phase, step] of ([ + ["promotion", workflow.delivery?.promotion], + ["acceptance", workflow.delivery?.acceptance], + ] as const)) { + const queueStateKey = step?.queueState; + if (!queueStateKey) continue; + const state = workflow.states[queueStateKey] as StateConfig | undefined; + if (!state || state.type !== StateType.QUEUE) continue; + const issues = await provider.listIssuesByLabel(state.label); + + for (const issue of issues) { + const routing = detectStepRouting(issue.labels, phase); + if (!routing) continue; + + const event = routing === "skip" + ? WorkflowEvent.SKIP + : phase === "promotion" + ? WorkflowEvent.PROMOTED + : WorkflowEvent.ACCEPTED; + const transition = state.on?.[event]; + if (!transition) continue; + + if (routing === "human") { + const candidate = await getCurrentCandidate(provider, issue.iid); + const candidateSatisfied = phase === "promotion" + ? candidate?.status === "active" + : candidate?.status === "accepted"; + if (!candidateSatisfied) continue; + } + + const targetKey = typeof transition === "string" ? transition : transition.target; + const actions = typeof transition === "object" ? transition.actions : undefined; + const targetState = workflow.states[targetKey]; + if (!targetState) continue; + + if (actions) { + for (const action of actions) { + switch (action) { + case Action.CLOSE_ISSUE: + await provider.closeIssue(issue.iid).catch(() => {}); + break; + case Action.REOPEN_ISSUE: + await provider.reopenIssue(issue.iid).catch(() => {}); + break; + } + } + } + + if (phase === "acceptance" && routing === "skip") { + await markCandidateStatus({ provider, issueId: issue.iid, status: "accepted", reason: "acceptance:skip" }).catch(() => {}); + } + + await provider.transitionLabel(issue.iid, state.label, targetState.label); + await auditLog(workspaceDir, "delivery_transition", { + project: projectName, + issueId: issue.iid, + phase, + from: state.label, + to: targetState.label, + reason: `${phase}:${routing}`, + }); + transitions++; + } + } + + return transitions; +} diff --git a/lib/services/heartbeat/passes.ts b/lib/services/heartbeat/passes.ts index fa946a0a..18936101 100644 --- a/lib/services/heartbeat/passes.ts +++ b/lib/services/heartbeat/passes.ts @@ -1,5 +1,5 @@ /** - * Heartbeat passes — health, review, review-skip, and test-skip passes. + * Heartbeat passes — health, review, review-skip, test-skip, and delivery passes. */ import type { PluginRuntime } from "openclaw/plugin-sdk"; import type { RunCommand } from "../../context.js"; @@ -13,6 +13,7 @@ import { import { reviewPass } from "./review.js"; import { reviewSkipPass } from "./review-skip.js"; import { testSkipPass } from "./test-skip.js"; +import { deliveryPass } from "./delivery.js"; import type { ResolvedConfig } from "../../config/types.js"; import { resolveNotifyChannel } from "../../workflow/index.js"; import { notify, getNotificationConfig } from "../../dispatch/notify.js"; @@ -274,3 +275,17 @@ export async function performTestSkipPass( provider, }); } + +export async function performDeliveryPass( + workspaceDir: string, + projectSlug: string, + provider: import("../../providers/provider.js").IssueProvider, + resolvedConfig: ResolvedConfig, +): Promise { + return deliveryPass({ + workspaceDir, + projectName: projectSlug, + workflow: resolvedConfig.workflow, + provider, + }); +} diff --git a/lib/services/heartbeat/tick-runner.ts b/lib/services/heartbeat/tick-runner.ts index eafdb1c2..1c009861 100644 --- a/lib/services/heartbeat/tick-runner.ts +++ b/lib/services/heartbeat/tick-runner.ts @@ -22,6 +22,7 @@ import { performReviewPass, performReviewSkipPass, performTestSkipPass, + performDeliveryPass, } from "./passes.js"; // --------------------------------------------------------------------------- @@ -35,6 +36,7 @@ export type TickResult = { totalReviewTransitions: number; totalReviewSkipTransitions: number; totalTestSkipTransitions: number; + totalDeliveryTransitions: number; }; // --------------------------------------------------------------------------- @@ -68,6 +70,7 @@ export async function tick(opts: { totalReviewTransitions: 0, totalReviewSkipTransitions: 0, totalTestSkipTransitions: 0, + totalDeliveryTransitions: 0, }; } @@ -78,6 +81,7 @@ export async function tick(opts: { totalReviewTransitions: 0, totalReviewSkipTransitions: 0, totalTestSkipTransitions: 0, + totalDeliveryTransitions: 0, }; const projectExecution = @@ -126,6 +130,11 @@ export async function tick(opts: { workspaceDir, slug, provider, resolvedConfig, ); + // Delivery pass: auto-transition skipped or human-completed promotion/acceptance queues + result.totalDeliveryTransitions += await performDeliveryPass( + workspaceDir, slug, provider, resolvedConfig, + ); + // Budget check: stop if we've hit the limit const remaining = config.maxPickupsPerTick - result.totalPickups; if (remaining <= 0) break; @@ -173,6 +182,7 @@ export async function tick(opts: { reviewTransitions: result.totalReviewTransitions, reviewSkipTransitions: result.totalReviewSkipTransitions, testSkipTransitions: result.totalTestSkipTransitions, + deliveryTransitions: result.totalDeliveryTransitions, pickups: result.totalPickups, skipped: result.totalSkipped, }); diff --git a/lib/services/pipeline.ts b/lib/services/pipeline.ts index efaa7cad..1ce1a680 100644 --- a/lib/services/pipeline.ts +++ b/lib/services/pipeline.ts @@ -20,6 +20,11 @@ import { getNextStateDescription, getCompletionEmoji, resolveNotifyChannel, + findStateKeyByLabel, + getDeliveryPhaseConfig, + getDeliveryPhaseForLabel, + recordPromotedCandidate, + markCandidateStatus, type CompletionRule, type WorkflowConfig, } from "../workflow/index.js"; @@ -274,6 +279,9 @@ export async function executeCompletion(opts: { // Then execute post-transition actions (close/reopen) // Finally deactivate worker (last — ensures label is set even if deactivation fails) const transitionedTo = rule.to as StateLabel; + const toStateKey = findStateKeyByLabel(workflow, transitionedTo); + const toPhase = getDeliveryPhaseForLabel(workflow, transitionedTo); + const fromPhase = getDeliveryPhaseForLabel(workflow, rule.from); if (transitionedTo === "Refining") { await provider.addComment(issueId, buildRefiningHoldComment({ role, @@ -286,6 +294,25 @@ export async function executeCompletion(opts: { } await provider.transitionLabel(issueId, rule.from as StateLabel, transitionedTo); + if (toPhase === "acceptance" && toStateKey && toStateKey === getDeliveryPhaseConfig(workflow, "acceptance")?.queueState) { + await recordPromotedCandidate({ + provider, + issueId, + repoPath, + runCommand: rc, + prUrl, + targetHint: transitionedTo, + }).catch(() => {}); + } + + if (toStateKey === "done" && fromPhase === "acceptance") { + await markCandidateStatus({ provider, issueId, status: "accepted", reason: summary }).catch(() => {}); + } + + if ((toStateKey === "toImprove" || toStateKey === "refining") && (fromPhase === "promotion" || fromPhase === "acceptance")) { + await markCandidateStatus({ provider, issueId, status: "invalidated", reason: summary }).catch(() => {}); + } + await recordLoopDiagnostic(workspaceDir, "work_finish_transition", { project: projectName, issueId, diff --git a/lib/services/tick.ts b/lib/services/tick.ts index a80ab728..45190a4e 100644 --- a/lib/services/tick.ts +++ b/lib/services/tick.ts @@ -120,46 +120,51 @@ export async function projectTick(opts: { continue; } - // Review policy gate: fallback for issues dispatched before step routing labels existed - if (role === "reviewer") { + const next = await findNextIssueForRole(provider, role, workflow, instanceName); + if (!next) continue; + + const { issue, label: currentLabel } = next; + const targetLabel = getActiveLabel(workflow, role); + + // Fallback policy gates for legacy issues that predate routing labels. + if (role === "reviewer" && currentLabel !== workflow.states[workflow.delivery?.promotion?.queueState ?? ""]?.label) { + const reviewRouting = detectStepRouting(issue.labels, "review"); const policy = workflow.reviewPolicy ?? ReviewPolicy.HUMAN; - if (policy === ReviewPolicy.HUMAN) { - skipped.push({ role, reason: "Review policy: human (heartbeat handles via PR polling)" }); - continue; - } - if (policy === ReviewPolicy.SKIP) { - skipped.push({ role, reason: "Review policy: skip (heartbeat handles via review-skip pass)" }); + if (!reviewRouting && (policy === ReviewPolicy.HUMAN || policy === ReviewPolicy.SKIP)) { + skipped.push({ role, reason: `Review policy: ${policy}` }); continue; } } - // Test policy gate: fallback for issues dispatched before test routing labels existed - if (role === "tester") { + if (role === "tester" && currentLabel !== workflow.states[workflow.delivery?.acceptance?.queueState ?? ""]?.label) { + const testRouting = detectStepRouting(issue.labels, "test"); const policy = workflow.testPolicy ?? TestPolicy.SKIP; - if (policy === TestPolicy.SKIP) { - skipped.push({ role, reason: "Test policy: skip (heartbeat handles via test-skip pass)" }); + if (!testRouting && policy === TestPolicy.SKIP) { + skipped.push({ role, reason: "Test policy: skip" }); continue; } } - const next = await findNextIssueForRole(provider, role, workflow, instanceName); - if (!next) continue; - - const { issue, label: currentLabel } = next; - const targetLabel = getActiveLabel(workflow, role); - - // Step routing: check for review:human / review:skip / test:skip labels + // Step routing: check for human/skip routing labels on queue phases if (role === "reviewer") { - const routing = detectStepRouting(issue.labels, "review"); + const reviewRouting = detectStepRouting(issue.labels, "review"); + const promotionRouting = currentLabel === workflow.states[workflow.delivery?.promotion?.queueState ?? ""]?.label + ? detectStepRouting(issue.labels, "promotion") + : null; + const routing = promotionRouting ?? reviewRouting; if (routing === "human" || routing === "skip") { - skipped.push({ role, reason: `review:${routing} label` }); + skipped.push({ role, reason: `${promotionRouting ? "promotion" : "review"}:${routing} label` }); continue; } } if (role === "tester") { - const routing = detectStepRouting(issue.labels, "test"); - if (routing === "skip") { - skipped.push({ role, reason: "test:skip label" }); + const testRouting = detectStepRouting(issue.labels, "test"); + const acceptanceRouting = currentLabel === workflow.states[workflow.delivery?.acceptance?.queueState ?? ""]?.label + ? detectStepRouting(issue.labels, "acceptance") + : null; + const routing = acceptanceRouting ?? testRouting; + if (routing === "human" || routing === "skip") { + skipped.push({ role, reason: `${acceptanceRouting ? "acceptance" : "test"}:${routing} label` }); continue; } } diff --git a/lib/tools/admin/project-register.ts b/lib/tools/admin/project-register.ts index 7c8c4e05..56068b27 100644 --- a/lib/tools/admin/project-register.ts +++ b/lib/tools/admin/project-register.ts @@ -284,7 +284,11 @@ export function createProjectRegisterTool(ctx: PluginContext) { testPhase: Object.values(resolvedConfig.workflow.states).some( (s) => s.role === "tester" && (s.type === "queue" || s.type === "active"), ), - hint: "The user can change the review policy or enable the test phase — call workflow_guide for the full reference.", + delivery: { + promotion: resolvedConfig.workflow.delivery?.promotion?.policy ?? "skip", + acceptance: resolvedConfig.workflow.delivery?.acceptance?.policy ?? "skip", + }, + hint: "The user can change review, testing, promotion, or acceptance policy — call workflow_guide for the full reference.", }; return jsonResult({ diff --git a/lib/tools/admin/project-status.ts b/lib/tools/admin/project-status.ts index 16631e02..5327b01d 100644 --- a/lib/tools/admin/project-status.ts +++ b/lib/tools/admin/project-status.ts @@ -84,6 +84,10 @@ export function createProjectStatusTool(ctx: PluginContext) { reviewPolicy: workflow.reviewPolicy ?? "human", roleExecution: workflow.roleExecution ?? ExecutionMode.PARALLEL, testPhase: hasTestPhase, + delivery: { + promotion: workflow.delivery?.promotion?.policy ?? "skip", + acceptance: workflow.delivery?.acceptance?.policy ?? "skip", + }, stateFlow: Object.entries(workflow.states) .map(([, s]) => s.label) .join(" → "), diff --git a/lib/tools/admin/workflow-guide.ts b/lib/tools/admin/workflow-guide.ts index 053cabec..b5ca1d91 100644 --- a/lib/tools/admin/workflow-guide.ts +++ b/lib/tools/admin/workflow-guide.ts @@ -22,7 +22,7 @@ export function createWorkflowGuideTool(_ctx: PluginContext) { `Reference guide for editing workflow.yaml. ` + `Call this BEFORE making any workflow configuration changes. ` + `Returns the full config structure, all valid values (enums, free-form fields), ` + - `the three-layer override system, and common recipes like enabling the test phase ` + + `the three-layer override system, and common recipes like enabling the test or delivery phases ` + `or changing the review policy.`, parameters: { type: "object", @@ -31,9 +31,9 @@ export function createWorkflowGuideTool(_ctx: PluginContext) { type: "string", description: "Optional: narrow to a specific topic. " + - 'Options: "overview", "states", "roles", "review", "testing", "timeouts", "overrides". ' + + 'Options: "overview", "states", "roles", "review", "testing", "delivery", "timeouts", "overrides". ' + "Omit for the full guide.", - enum: ["overview", "states", "roles", "review", "testing", "timeouts", "overrides"], + enum: ["overview", "states", "roles", "review", "testing", "delivery", "timeouts", "overrides"], }, }, }, @@ -49,6 +49,7 @@ export function createWorkflowGuideTool(_ctx: PluginContext) { roles: buildRolesSection(), review: buildReviewSection(), testing: buildTestingSection(), + delivery: buildDeliverySection(), timeouts: buildTimeoutsSection(), overrides: buildOverridesSection(dataDir), }; @@ -107,6 +108,44 @@ workflow: This changes only the senior developer model and review policy; everything else inherits.`; } +function buildDeliverySection(): string { + return `# Delivery Phases + +Delivery is modeled as two optional workflow phases after testing: +- **promotion**: candidate promotion into a release lane +- **acceptance**: acceptance of the promoted candidate + +## Delivery config shape + +\`\`\`yaml +workflow: + delivery: + promotion: + policy: skip # skip | agent | human + queueState: toPromote + activeState: promoting + acceptance: + policy: skip # skip | agent | human + queueState: toAccept + activeState: accepting +\`\`\` + +## Rules +- If a delivery phase is omitted or set to \`skip\`, existing projects keep working unchanged. +- \`queueState\` must point to a queue state for the correct role. +- \`activeState\` must point to an active state for the correct role. +- Promotion should represent candidate promotion, not generic testing. +- Acceptance should represent acceptance of the promoted candidate. +- Environment-specific deploy mechanics stay in project runbooks, not core workflow semantics. + +## Routing labels +- Promotion uses \`promotion:human\`, \`promotion:agent\`, \`promotion:skip\` +- Acceptance uses \`acceptance:human\`, \`acceptance:agent\`, \`acceptance:skip\` + +## Default behavior +The built-in workflow defines delivery states, but both phases default to \`skip\`. That means older projects remain backward compatible until they opt in.`; +} + function buildStatesSection(): string { return `# Workflow States diff --git a/lib/workflow/candidate-provenance.ts b/lib/workflow/candidate-provenance.ts new file mode 100644 index 00000000..07b6eaa1 --- /dev/null +++ b/lib/workflow/candidate-provenance.ts @@ -0,0 +1,112 @@ +import type { IssueProvider, IssueComment } from "../providers/provider.js"; +import type { RunCommand } from "../context.js"; + +const MARKER = "devclaw:candidate-record"; + +export type CandidateStatus = "active" | "accepted" | "invalidated"; + +export type CandidateRecord = { + issueId: number; + prUrl?: string | null; + commitSha?: string | null; + candidateId?: string | null; + targetHint?: string | null; + status: CandidateStatus; + promotedAt?: string; + acceptedAt?: string; + invalidatedAt?: string; + reason?: string | null; +}; + +export async function getCurrentCandidate(provider: IssueProvider, issueId: number): Promise { + const comments = await provider.listComments(issueId); + return findLatestCandidateRecord(comments); +} + +export async function recordPromotedCandidate(opts: { + provider: IssueProvider; + issueId: number; + repoPath: string; + runCommand: RunCommand; + prUrl?: string | null; + targetHint?: string | null; +}): Promise { + const commitSha = await getHeadSha(opts.repoPath, opts.runCommand); + const promotedAt = new Date().toISOString(); + const candidateId = commitSha ? commitSha.slice(0, 12) : `issue-${opts.issueId}-${Date.now()}`; + const record: CandidateRecord = { + issueId: opts.issueId, + prUrl: opts.prUrl ?? null, + commitSha, + candidateId, + targetHint: opts.targetHint ?? null, + status: "active", + promotedAt, + }; + await opts.provider.addComment(opts.issueId, renderCandidateRecord(record)); + return record; +} + +export async function markCandidateStatus(opts: { + provider: IssueProvider; + issueId: number; + status: Exclude; + reason?: string; +}): Promise { + const current = await getCurrentCandidate(opts.provider, opts.issueId); + if (!current) return null; + const now = new Date().toISOString(); + const next: CandidateRecord = { + ...current, + status: opts.status, + acceptedAt: opts.status === "accepted" ? now : current.acceptedAt, + invalidatedAt: opts.status === "invalidated" ? now : current.invalidatedAt, + reason: opts.reason ?? current.reason ?? null, + }; + await opts.provider.addComment(opts.issueId, renderCandidateRecord(next)); + return next; +} + +export function renderCandidateRecord(record: CandidateRecord): string { + const payload = JSON.stringify(record); + const lines = [ + ``, + "## DevClaw Candidate Record", + "", + `- status: ${record.status}`, + `- candidate: ${record.candidateId ?? "unknown"}`, + `- commit: ${record.commitSha ?? "unknown"}`, + `- target: ${record.targetHint ?? "unspecified"}`, + ]; + if (record.prUrl) lines.push(`- PR: ${record.prUrl}`); + if (record.reason) lines.push(`- reason: ${record.reason}`); + return lines.join("\n"); +} + +function findLatestCandidateRecord(comments: IssueComment[]): CandidateRecord | null { + for (let i = comments.length - 1; i >= 0; i--) { + const comment = comments[i]; + const record = parseCandidateRecord(comment?.body ?? ""); + if (record) return record; + } + return null; +} + +function parseCandidateRecord(body: string): CandidateRecord | null { + const match = body.match(new RegExp(``)); + if (!match?.[1]) return null; + try { + return JSON.parse(match[1]) as CandidateRecord; + } catch { + return null; + } +} + +async function getHeadSha(repoPath: string, runCommand: RunCommand): Promise { + try { + const result = await runCommand(["git", "rev-parse", "HEAD"], { cwd: repoPath, timeoutMs: 10_000 }); + return result.stdout.trim() || null; + } catch { + return null; + } +} diff --git a/lib/workflow/defaults.ts b/lib/workflow/defaults.ts index 5e7d1cfb..9690615f 100644 --- a/lib/workflow/defaults.ts +++ b/lib/workflow/defaults.ts @@ -16,6 +16,10 @@ export const DEFAULT_WORKFLOW: WorkflowConfig = { initial: "planning", reviewPolicy: ReviewPolicy.HUMAN, testPolicy: TestPolicy.SKIP, + delivery: { + promotion: { policy: "skip", queueState: "toPromote", activeState: "promoting" }, + acceptance: { policy: "skip", queueState: "toAccept", activeState: "accepting" }, + }, roleExecution: ExecutionMode.PARALLEL, states: { // ── Main pipeline (happy path) ────────────────────────────── @@ -88,6 +92,60 @@ export const DEFAULT_WORKFLOW: WorkflowConfig = { role: "tester", label: "Testing", color: "#9b59b6", + on: { + [WorkflowEvent.PASS]: "toPromote", + [WorkflowEvent.FAIL]: { target: "toImprove", actions: [Action.REOPEN_ISSUE] }, + [WorkflowEvent.REFINE]: "refining", + [WorkflowEvent.BLOCKED]: "refining", + }, + }, + toPromote: { + type: StateType.QUEUE, + role: "reviewer", + label: "To Promote", + color: "#1d76db", + priority: 2, + on: { + [WorkflowEvent.PICKUP]: "promoting", + [WorkflowEvent.SKIP]: "toAccept", + [WorkflowEvent.PROMOTED]: "toAccept", + [WorkflowEvent.FAIL]: "toImprove", + [WorkflowEvent.DEMOTED]: "toImprove", + [WorkflowEvent.BLOCKED]: "refining", + }, + }, + promoting: { + type: StateType.ACTIVE, + role: "reviewer", + label: "Promoting", + color: "#6ea8fe", + on: { + [WorkflowEvent.APPROVE]: "toAccept", + [WorkflowEvent.REJECT]: "toImprove", + [WorkflowEvent.BLOCKED]: "refining", + }, + }, + toAccept: { + type: StateType.QUEUE, + role: "tester", + label: "To Accept", + color: "#20c997", + priority: 2, + on: { + [WorkflowEvent.PICKUP]: "accepting", + [WorkflowEvent.SKIP]: { target: "done", actions: [Action.CLOSE_ISSUE] }, + [WorkflowEvent.ACCEPTED]: { target: "done", actions: [Action.CLOSE_ISSUE] }, + [WorkflowEvent.FAIL]: { target: "toImprove", actions: [Action.REOPEN_ISSUE] }, + [WorkflowEvent.DEMOTED]: { target: "toImprove", actions: [Action.REOPEN_ISSUE] }, + [WorkflowEvent.REFINE]: "refining", + [WorkflowEvent.BLOCKED]: "refining", + }, + }, + accepting: { + type: StateType.ACTIVE, + role: "tester", + label: "Accepting", + color: "#8ce0c4", on: { [WorkflowEvent.PASS]: { target: "done", actions: [Action.CLOSE_ISSUE] }, [WorkflowEvent.FAIL]: { target: "toImprove", actions: [Action.REOPEN_ISSUE] }, diff --git a/lib/workflow/index.ts b/lib/workflow/index.ts index edb501bc..c364324e 100644 --- a/lib/workflow/index.ts +++ b/lib/workflow/index.ts @@ -9,3 +9,4 @@ export * from "./defaults.js"; export * from "./queries.js"; export * from "./labels.js"; export * from "./completion.js"; +export * from "./candidate-provenance.js"; diff --git a/lib/workflow/labels.ts b/lib/workflow/labels.ts index 773ed0b1..8cfac5a4 100644 --- a/lib/workflow/labels.ts +++ b/lib/workflow/labels.ts @@ -1,9 +1,8 @@ /** * workflow/labels.ts — Label formatting, detection, and routing helpers. */ -import type { WorkflowConfig, ReviewPolicy, TestPolicy } from "./types.js"; -import { ReviewPolicy as RP, TestPolicy as TP } from "./types.js"; -import { getLabelColors } from "./queries.js"; +import type { WorkflowConfig, ReviewPolicy, TestPolicy, DeliveryPolicy } from "./types.js"; +import { ReviewPolicy as RP, TestPolicy as TP, DeliveryPolicy as DP } from "./types.js"; // --------------------------------------------------------------------------- // Step routing labels @@ -20,7 +19,9 @@ export type StepRoutingValue = (typeof StepRouting)[keyof typeof StepRouting]; /** Known step routing labels (created on the provider during project registration). */ export const STEP_ROUTING_LABELS: readonly string[] = [ "review:human", "review:agent", "review:skip", - "test:skip", + "test:skip", "test:agent", + "promotion:human", "promotion:agent", "promotion:skip", + "acceptance:human", "acceptance:agent", "acceptance:skip", ]; /** Step routing label color. */ @@ -115,6 +116,15 @@ export function resolveTestRouting( return "test:skip"; } +export function resolveDeliveryRouting( + policy: DeliveryPolicy, + phase: "promotion" | "acceptance", +): "promotion:human" | "promotion:agent" | "promotion:skip" | "acceptance:human" | "acceptance:agent" | "acceptance:skip" { + if (policy === DP.HUMAN) return `${phase}:human`; + if (policy === DP.AGENT) return `${phase}:agent`; + return `${phase}:skip`; +} + // --------------------------------------------------------------------------- // Role labels // --------------------------------------------------------------------------- diff --git a/lib/workflow/queries.ts b/lib/workflow/queries.ts index 386bd297..78e27a52 100644 --- a/lib/workflow/queries.ts +++ b/lib/workflow/queries.ts @@ -5,6 +5,7 @@ import { type WorkflowConfig, type StateConfig, type Role, + type DeliveryPhase, StateType, WorkflowEvent, } from "./types.js"; @@ -195,6 +196,33 @@ export function hasTestPhase(workflow: WorkflowConfig): boolean { ); } +export function getDeliveryPhaseConfig(workflow: WorkflowConfig, phase: DeliveryPhase) { + return workflow.delivery?.[phase]; +} + +export function getDeliveryQueueLabel(workflow: WorkflowConfig, phase: DeliveryPhase): string | null { + const key = getDeliveryPhaseConfig(workflow, phase)?.queueState; + return key ? workflow.states[key]?.label ?? null : null; +} + +export function getDeliveryActiveLabel(workflow: WorkflowConfig, phase: DeliveryPhase): string | null { + const key = getDeliveryPhaseConfig(workflow, phase)?.activeState; + return key ? workflow.states[key]?.label ?? null : null; +} + +export function hasDeliveryPhase(workflow: WorkflowConfig, phase: DeliveryPhase): boolean { + return getDeliveryQueueLabel(workflow, phase) != null; +} + +export function getDeliveryPhaseForLabel(workflow: WorkflowConfig, label: string): DeliveryPhase | null { + for (const phase of ["promotion", "acceptance"] as DeliveryPhase[]) { + if (getDeliveryQueueLabel(workflow, phase) === label || getDeliveryActiveLabel(workflow, phase) === label) { + return phase; + } + } + return null; +} + /** * Load workflow config for a project. * Delegates to loadConfig() which handles the three-layer merge. diff --git a/lib/workflow/types.ts b/lib/workflow/types.ts index 59992255..39f2e4a4 100644 --- a/lib/workflow/types.ts +++ b/lib/workflow/types.ts @@ -33,6 +33,20 @@ export const TestPolicy = { } as const; export type TestPolicy = (typeof TestPolicy)[keyof typeof TestPolicy]; +/** Delivery-phase policy for promotion/acceptance routing. */ +export const DeliveryPolicy = { + HUMAN: "human", + AGENT: "agent", + SKIP: "skip", +} as const; +export type DeliveryPolicy = (typeof DeliveryPolicy)[keyof typeof DeliveryPolicy]; + +export const DeliveryPhase = { + PROMOTION: "promotion", + ACCEPTANCE: "acceptance", +} as const; +export type DeliveryPhase = (typeof DeliveryPhase)[keyof typeof DeliveryPhase]; + /** Role identifier. Built-in: "developer", "tester", "architect". Extensible via config. */ export type Role = string; /** Action identifier. Built-in actions listed in `Action`; custom actions are also valid strings. */ @@ -60,6 +74,9 @@ export const WorkflowEvent = { COMPLETE: "COMPLETE", REVIEW: "REVIEW", APPROVED: "APPROVED", + PROMOTED: "PROMOTED", + ACCEPTED: "ACCEPTED", + DEMOTED: "DEMOTED", MERGE_FAILED: "MERGE_FAILED", CHANGES_REQUESTED: "CHANGES_REQUESTED", MERGE_CONFLICT: "MERGE_CONFLICT", @@ -94,6 +111,18 @@ export type WorkflowConfig = { initial: string; reviewPolicy?: ReviewPolicy; testPolicy?: TestPolicy; + delivery?: { + promotion?: { + policy?: DeliveryPolicy; + queueState?: string; + activeState?: string; + }; + acceptance?: { + policy?: DeliveryPolicy; + queueState?: string; + activeState?: string; + }; + }; roleExecution?: ExecutionMode; /** Default max workers per level across all roles. Default: 2. */ maxWorkersPerLevel?: number; From 9540f1a68e731bf9ad0ac82e8d53cfc074faa48f Mon Sep 17 00:00:00 2001 From: fujiwaranosai850 Date: Thu, 7 May 2026 00:27:48 +0000 Subject: [PATCH 02/30] fix delivery phase dispatch and promotion provenance --- lib/services/delivery-phases.test.ts | 93 +++++++++++++++++++++++++++ lib/services/heartbeat/delivery.ts | 23 +++++-- lib/services/heartbeat/passes.ts | 4 ++ lib/services/heartbeat/tick-runner.ts | 2 +- lib/services/pipeline.ts | 15 +++-- lib/services/tick.ts | 3 +- lib/tools/worker/work-finish.ts | 6 +- lib/workflow/completion.ts | 25 +++++-- lib/workflow/queries.ts | 50 +++++++++++++- 9 files changed, 200 insertions(+), 21 deletions(-) create mode 100644 lib/services/delivery-phases.test.ts diff --git a/lib/services/delivery-phases.test.ts b/lib/services/delivery-phases.test.ts new file mode 100644 index 00000000..031a5863 --- /dev/null +++ b/lib/services/delivery-phases.test.ts @@ -0,0 +1,93 @@ +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 } 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("records candidate provenance when promotion is human-routed", 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, 1); + + const transitionCalls = h.provider.callsTo("transitionLabel"); + assert.deepStrictEqual(transitionCalls.at(-1)?.args, { + issueId: 44, + from: "To Promote", + to: "To Accept", + }); + + const comments = await h.provider.listComments(44); + assert.match(comments.at(-1)?.body ?? "", /devclaw:candidate-record/); + assert.match(comments.at(-1)?.body ?? "", /status: active/); + }); +}); diff --git a/lib/services/heartbeat/delivery.ts b/lib/services/heartbeat/delivery.ts index e78912d7..4fb92a3f 100644 --- a/lib/services/heartbeat/delivery.ts +++ b/lib/services/heartbeat/delivery.ts @@ -1,10 +1,12 @@ import type { IssueProvider } from "../../providers/provider.js"; +import type { RunCommand } from "../../context.js"; import { Action, StateType, WorkflowEvent, getCurrentCandidate, markCandidateStatus, + recordPromotedCandidate, type WorkflowConfig, type StateConfig, } from "../../workflow/index.js"; @@ -16,8 +18,10 @@ export async function deliveryPass(opts: { projectName: string; workflow: WorkflowConfig; provider: IssueProvider; + repoPath: string; + runCommand: RunCommand; }): Promise { - const { workspaceDir, projectName, workflow, provider } = opts; + const { workspaceDir, projectName, workflow, provider, repoPath, runCommand } = opts; let transitions = 0; for (const [phase, step] of ([ @@ -44,10 +48,19 @@ export async function deliveryPass(opts: { if (routing === "human") { const candidate = await getCurrentCandidate(provider, issue.iid); - const candidateSatisfied = phase === "promotion" - ? candidate?.status === "active" - : candidate?.status === "accepted"; - if (!candidateSatisfied) continue; + if (phase === "promotion") { + if (candidate?.status !== "active") { + await recordPromotedCandidate({ + provider, + issueId: issue.iid, + repoPath, + runCommand, + targetHint: state.label, + }).catch(() => {}); + } + } else if (candidate?.status !== "accepted") { + continue; + } } const targetKey = typeof transition === "string" ? transition : transition.target; diff --git a/lib/services/heartbeat/passes.ts b/lib/services/heartbeat/passes.ts index 18936101..c83a75ab 100644 --- a/lib/services/heartbeat/passes.ts +++ b/lib/services/heartbeat/passes.ts @@ -279,13 +279,17 @@ export async function performTestSkipPass( export async function performDeliveryPass( workspaceDir: string, projectSlug: string, + repoPath: string, provider: import("../../providers/provider.js").IssueProvider, resolvedConfig: ResolvedConfig, + runCommand: import("../../context.js").RunCommand, ): Promise { return deliveryPass({ workspaceDir, projectName: projectSlug, workflow: resolvedConfig.workflow, provider, + repoPath, + runCommand, }); } diff --git a/lib/services/heartbeat/tick-runner.ts b/lib/services/heartbeat/tick-runner.ts index 1c009861..4c2066da 100644 --- a/lib/services/heartbeat/tick-runner.ts +++ b/lib/services/heartbeat/tick-runner.ts @@ -132,7 +132,7 @@ export async function tick(opts: { // Delivery pass: auto-transition skipped or human-completed promotion/acceptance queues result.totalDeliveryTransitions += await performDeliveryPass( - workspaceDir, slug, provider, resolvedConfig, + workspaceDir, slug, project.repo, provider, resolvedConfig, runCommand, ); // Budget check: stop if we've hit the limit diff --git a/lib/services/pipeline.ts b/lib/services/pipeline.ts index 1ce1a680..a4e7bbac 100644 --- a/lib/services/pipeline.ts +++ b/lib/services/pipeline.ts @@ -19,9 +19,9 @@ import { getCompletionRule, getNextStateDescription, getCompletionEmoji, + getCurrentStateLabel, resolveNotifyChannel, findStateKeyByLabel, - getDeliveryPhaseConfig, getDeliveryPhaseForLabel, recordPromotedCandidate, markCandidateStatus, @@ -109,8 +109,9 @@ export function getRule( role: string, result: string, workflow: WorkflowConfig = DEFAULT_WORKFLOW, + currentLabel?: string | null, ): CompletionRule | undefined { - return getCompletionRule(workflow, role, result) ?? undefined; + return getCompletionRule(workflow, role, result, currentLabel) ?? undefined; } /** @@ -152,7 +153,9 @@ export async function executeCompletion(opts: { } = opts; const key = `${role}:${result}`; - const rule = getCompletionRule(workflow, role, result); + const issue = await provider.getIssue(issueId); + const currentLabel = getCurrentStateLabel(issue.labels, workflow); + const rule = getCompletionRule(workflow, role, result, currentLabel); if (!rule) throw new Error(`No completion rule for ${key}`); const { timeouts } = await loadConfig(workspaceDir, projectName); @@ -200,12 +203,10 @@ export async function executeCompletion(opts: { } } - // Get issue early (for URL in notification + channel routing) - const issue = await provider.getIssue(issueId); const notifyTarget = resolveNotifyChannel(issue.labels, channels); // Get next state description from workflow - const nextState = getNextStateDescription(workflow, role, result); + const nextState = getNextStateDescription(workflow, role, result, currentLabel); // Retrieve worker name from project state (best-effort) let workerName: string | undefined; @@ -294,7 +295,7 @@ export async function executeCompletion(opts: { } await provider.transitionLabel(issueId, rule.from as StateLabel, transitionedTo); - if (toPhase === "acceptance" && toStateKey && toStateKey === getDeliveryPhaseConfig(workflow, "acceptance")?.queueState) { + if (fromPhase === "promotion" && result === "done") { await recordPromotedCandidate({ provider, issueId, diff --git a/lib/services/tick.ts b/lib/services/tick.ts index 45190a4e..e459732e 100644 --- a/lib/services/tick.ts +++ b/lib/services/tick.ts @@ -19,6 +19,7 @@ import { ReviewPolicy, TestPolicy, getActiveLabel, + getActiveLabelForQueueLabel, type WorkflowConfig, type Role, } from "../workflow/index.js"; @@ -124,7 +125,7 @@ export async function projectTick(opts: { if (!next) continue; const { issue, label: currentLabel } = next; - const targetLabel = getActiveLabel(workflow, role); + const targetLabel = getActiveLabelForQueueLabel(workflow, role, currentLabel); // Fallback policy gates for legacy issues that predate routing labels. if (role === "reviewer" && currentLabel !== workflow.states[workflow.delivery?.promotion?.queueState ?? ""]?.label) { diff --git a/lib/tools/worker/work-finish.ts b/lib/tools/worker/work-finish.ts index a457c230..b3b23cb0 100644 --- a/lib/tools/worker/work-finish.ts +++ b/lib/tools/worker/work-finish.ts @@ -18,7 +18,7 @@ import { log as auditLog } from "../../audit.js"; import { DATA_DIR } from "../../setup/migrate-layout.js"; import { requireWorkspaceDir, resolveChannelId, resolveProject, resolveProvider } from "../helpers.js"; import { getAllRoleIds, isValidResult, getCompletionResults } from "../../roles/index.js"; -import { loadWorkflow } from "../../workflow/index.js"; +import { getCurrentStateLabel, loadWorkflow } from "../../workflow/index.js"; /** * Get the current git branch name. @@ -261,8 +261,10 @@ export function createWorkFinishTool(ctx: PluginContext) { const { provider } = await resolveProvider(project, ctx.runCommand); const workflow = await loadWorkflow(workspaceDir, project.name); + const issue = await provider.getIssue(issueId); + const currentLabel = getCurrentStateLabel(issue.labels, workflow); - if (!getRule(role, result, workflow)) + if (!getRule(role, result, workflow, currentLabel)) throw new Error(`Invalid completion: ${role}:${result}`); const repoPath = resolveRepoPath(project.repo); diff --git a/lib/workflow/completion.ts b/lib/workflow/completion.ts index 1ec870c9..d84c1c32 100644 --- a/lib/workflow/completion.ts +++ b/lib/workflow/completion.ts @@ -8,7 +8,7 @@ import { StateType, WorkflowEvent, } from "./types.js"; -import { getActiveLabel, findStateKeyByLabel, findStateByLabel } from "./queries.js"; +import { getActiveLabel, findStateKeyByLabel, findStateByLabel, getActiveLabelForQueueLabel } from "./queries.js"; /** * Map completion result to workflow transition event name. @@ -27,13 +27,29 @@ export function getCompletionRule( workflow: WorkflowConfig, role: Role, result: string, + currentLabel?: string | null, ): CompletionRule | null { const event = resultToEvent(result); let activeLabel: string; try { - activeLabel = getActiveLabel(workflow, role); - } catch { return null; } + if (currentLabel) { + const currentKey = findStateKeyByLabel(workflow, currentLabel); + const currentState = currentKey ? workflow.states[currentKey] : null; + if (currentState?.type === StateType.ACTIVE && currentState.role === role) { + activeLabel = currentLabel; + } else { + activeLabel = getActiveLabelForQueueLabel(workflow, role, currentLabel); + } + } else { + activeLabel = getActiveLabel(workflow, role); + } + } catch { + if (!currentLabel) return null; + try { + activeLabel = getActiveLabel(workflow, role); + } catch { return null; } + } const activeKey = findStateKeyByLabel(workflow, activeLabel); if (!activeKey) return null; @@ -63,8 +79,9 @@ export function getNextStateDescription( workflow: WorkflowConfig, role: Role, result: string, + currentLabel?: string | null, ): string { - const rule = getCompletionRule(workflow, role, result); + const rule = getCompletionRule(workflow, role, result, currentLabel); if (!rule) return ""; const targetState = findStateByLabel(workflow, rule.to); diff --git a/lib/workflow/queries.ts b/lib/workflow/queries.ts index 78e27a52..8e672dbb 100644 --- a/lib/workflow/queries.ts +++ b/lib/workflow/queries.ts @@ -75,6 +75,32 @@ export function getActiveLabel(workflow: WorkflowConfig, role: Role): string { return state.label; } +/** + * Get the active label that a queue label picks up into. + */ +export function getActiveLabelForQueueLabel( + workflow: WorkflowConfig, + role: Role, + queueLabel: string, +): string { + const queueStateKey = findStateKeyByLabel(workflow, queueLabel); + if (!queueStateKey) throw new Error(`No workflow state for queue label "${queueLabel}"`); + + const queueState = workflow.states[queueStateKey]; + if (queueState.type !== StateType.QUEUE || queueState.role !== role) { + throw new Error(`Label "${queueLabel}" is not a ${role} queue state`); + } + + const pickup = queueState.on?.[WorkflowEvent.PICKUP]; + const targetKey = typeof pickup === "string" ? pickup : pickup?.target; + const targetState = targetKey ? workflow.states[targetKey] : null; + if (!targetState || targetState.type !== StateType.ACTIVE || targetState.role !== role) { + throw new Error(`Queue label "${queueLabel}" does not pick up into an active ${role} state`); + } + + return targetState.label; +} + /** * Get the revert label for a role (first queue state for that role). */ @@ -87,7 +113,8 @@ export function getRevertLabel(workflow: WorkflowConfig, role: Role): string { for (const [, state] of Object.entries(workflow.states)) { if (state.type !== StateType.QUEUE || state.role !== role) continue; const pickup = state.on?.[WorkflowEvent.PICKUP]; - if (pickup === activeStateKey) { + const targetKey = typeof pickup === "string" ? pickup : pickup?.target; + if (targetKey === activeStateKey) { return state.label; } } @@ -95,6 +122,27 @@ export function getRevertLabel(workflow: WorkflowConfig, role: Role): string { return getQueueLabels(workflow, role)[0] ?? ""; } +/** + * Get the queue label that leads into a specific active label. + */ +export function getQueueLabelForActiveLabel( + workflow: WorkflowConfig, + role: Role, + activeLabel: string, +): string { + const activeStateKey = findStateKeyByLabel(workflow, activeLabel); + if (!activeStateKey) throw new Error(`No workflow state for active label "${activeLabel}"`); + + for (const state of Object.values(workflow.states)) { + if (state.type !== StateType.QUEUE || state.role !== role) continue; + const pickup = state.on?.[WorkflowEvent.PICKUP]; + const targetKey = typeof pickup === "string" ? pickup : pickup?.target; + if (targetKey === activeStateKey) return state.label; + } + + throw new Error(`No ${role} queue state picks up into "${activeLabel}"`); +} + /** * Detect role from a label. */ From 885482d702d9f27c2885a115eed293c3a0d2f996 Mon Sep 17 00:00:00 2001 From: fujiwaranosai850 Date: Thu, 7 May 2026 00:40:39 +0000 Subject: [PATCH 03/30] fix: enforce explicit human delivery gates --- lib/services/delivery-phases.test.ts | 87 +++++++++++++++++++++++++--- lib/services/heartbeat/delivery.ts | 20 ++----- 2 files changed, 83 insertions(+), 24 deletions(-) diff --git a/lib/services/delivery-phases.test.ts b/lib/services/delivery-phases.test.ts index 031a5863..a353d128 100644 --- a/lib/services/delivery-phases.test.ts +++ b/lib/services/delivery-phases.test.ts @@ -3,7 +3,7 @@ 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 } from "../workflow/index.js"; +import { DEFAULT_WORKFLOW, getCompletionRule, renderCandidateRecord } from "../workflow/index.js"; describe("delivery phase routing", () => { let h: TestHarness; @@ -64,7 +64,7 @@ describe("delivery phase routing", () => { ]); }); - it("records candidate provenance when promotion is human-routed", async () => { + 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"] }); @@ -77,17 +77,86 @@ describe("delivery phase routing", () => { runCommand: h.runCommand, }); - assert.strictEqual(transitions, 1); + assert.strictEqual(transitions, 0); + assert.deepStrictEqual(h.provider.callsTo("transitionLabel"), []); + }); - const transitionCalls = h.provider.callsTo("transitionLabel"); - assert.deepStrictEqual(transitionCalls.at(-1)?.args, { - issueId: 44, + 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", }); + }); - const comments = await h.provider.listComments(44); - assert.match(comments.at(-1)?.body ?? "", /devclaw:candidate-record/); - assert.match(comments.at(-1)?.body ?? "", /status: active/); + it("advances human-routed acceptance only after the candidate is explicitly accepted", 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, renderCandidateRecord({ + issueId: 46, + candidateId: "cand-46", + commitSha: "def456", + targetHint: "candidate", + status: "accepted", + promotedAt: new Date().toISOString(), + acceptedAt: new Date().toISOString(), + })); + + 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", + }); }); }); diff --git a/lib/services/heartbeat/delivery.ts b/lib/services/heartbeat/delivery.ts index 4fb92a3f..d91335d8 100644 --- a/lib/services/heartbeat/delivery.ts +++ b/lib/services/heartbeat/delivery.ts @@ -6,7 +6,6 @@ import { WorkflowEvent, getCurrentCandidate, markCandidateStatus, - recordPromotedCandidate, type WorkflowConfig, type StateConfig, } from "../../workflow/index.js"; @@ -21,7 +20,7 @@ export async function deliveryPass(opts: { repoPath: string; runCommand: RunCommand; }): Promise { - const { workspaceDir, projectName, workflow, provider, repoPath, runCommand } = opts; + const { workspaceDir, projectName, workflow, provider } = opts; let transitions = 0; for (const [phase, step] of ([ @@ -48,19 +47,10 @@ export async function deliveryPass(opts: { if (routing === "human") { const candidate = await getCurrentCandidate(provider, issue.iid); - if (phase === "promotion") { - if (candidate?.status !== "active") { - await recordPromotedCandidate({ - provider, - issueId: issue.iid, - repoPath, - runCommand, - targetHint: state.label, - }).catch(() => {}); - } - } else if (candidate?.status !== "accepted") { - continue; - } + const ready = phase === "promotion" + ? candidate?.status === "active" + : candidate?.status === "accepted"; + if (!ready) continue; } const targetKey = typeof transition === "string" ? transition : transition.target; From 95adf3f6e93de5c4b3a47d1265da1692a346ee75 Mon Sep 17 00:00:00 2001 From: fujiwaranosai850 Date: Thu, 7 May 2026 06:17:48 +0000 Subject: [PATCH 04/30] fix delivery promotion provenance and role validation --- lib/config/schema.test.ts | 28 +++++++++++++++++ lib/config/schema.ts | 4 +++ lib/services/pipeline-delivery.test.ts | 42 ++++++++++++++++++++++++++ lib/services/pipeline.ts | 2 +- 4 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 lib/config/schema.test.ts create mode 100644 lib/services/pipeline-delivery.test.ts diff --git a/lib/config/schema.test.ts b/lib/config/schema.test.ts new file mode 100644 index 00000000..7ad6b001 --- /dev/null +++ b/lib/config/schema.test.ts @@ -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")); + }); +}); diff --git a/lib/config/schema.ts b/lib/config/schema.ts index 9f263049..4c59f5d4 100644 --- a/lib/config/schema.ts +++ b/lib/config/schema.ts @@ -122,9 +122,13 @@ export function validateWorkflowIntegrity( } 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); diff --git a/lib/services/pipeline-delivery.test.ts b/lib/services/pipeline-delivery.test.ts new file mode 100644 index 00000000..6e6f04db --- /dev/null +++ b/lib/services/pipeline-delivery.test.ts @@ -0,0 +1,42 @@ +import { afterEach, describe, it } from "node:test"; +import assert from "node:assert"; +import { createTestHarness, type TestHarness } from "../testing/index.js"; +import { executeCompletion } from "./pipeline.js"; +import { DEFAULT_WORKFLOW, getCurrentCandidate } from "../workflow/index.js"; + +describe("executeCompletion delivery provenance", () => { + let h: TestHarness; + + afterEach(async () => { + if (h) await h.cleanup(); + }); + + it("records an active candidate when promotion completes into acceptance", async () => { + h = await createTestHarness({ + workers: { + reviewer: { active: true, issueId: "26", level: "junior" }, + }, + }); + h.provider.seedIssue({ iid: 26, title: "Promote PR", labels: ["Promoting"] }); + + const output = await executeCompletion({ + workspaceDir: h.workspaceDir, + projectSlug: h.project.slug, + channels: h.project.channels, + role: "reviewer", + result: "approve", + issueId: 26, + summary: "Promoted candidate", + provider: h.provider, + repoPath: "/tmp/test-repo", + projectName: "test-project", + workflow: DEFAULT_WORKFLOW, + runCommand: h.runCommand, + }); + + assert.strictEqual(output.labelTransition, "Promoting → To Accept"); + const candidate = await getCurrentCandidate(h.provider, 26); + assert.ok(candidate, "Expected candidate provenance to be recorded"); + assert.strictEqual(candidate?.status, "active"); + }); +}); diff --git a/lib/services/pipeline.ts b/lib/services/pipeline.ts index a4e7bbac..074df178 100644 --- a/lib/services/pipeline.ts +++ b/lib/services/pipeline.ts @@ -295,7 +295,7 @@ export async function executeCompletion(opts: { } await provider.transitionLabel(issueId, rule.from as StateLabel, transitionedTo); - if (fromPhase === "promotion" && result === "done") { + if (fromPhase === "promotion" && toPhase === "acceptance") { await recordPromotedCandidate({ provider, issueId, From 0d30dddc122b6a9120597d7df3da39aeaccfe426 Mon Sep 17 00:00:00 2001 From: fujiwaranosai850 Date: Sat, 9 May 2026 05:05:11 +0000 Subject: [PATCH 05/30] docs: define release agent contract and doc gaps --- dev/design/release-agent-contract.md | 160 ++++++++++++++++++ .../developing-devclaw-with-openclaw.md | 6 + docs/CONFIGURATION.md | 18 +- docs/WORKFLOW.md | 47 +++++ lib/tools/admin/workflow-guide.ts | 6 + 5 files changed, 236 insertions(+), 1 deletion(-) create mode 100644 dev/design/release-agent-contract.md diff --git a/dev/design/release-agent-contract.md b/dev/design/release-agent-contract.md new file mode 100644 index 00000000..e822c5f0 --- /dev/null +++ b/dev/design/release-agent-contract.md @@ -0,0 +1,160 @@ +# Release agent contract + +This document describes the intended operator-facing contract for the DevClaw release agent. + +It is a design target for the next implementation pass. The current codebase already supports delivery-phase hooks such as `To Promote`, `Promoting`, `To Accept`, and `Accepting`, but it does not yet fully implement the contract below. + +## Core idea + +Release is a distinct process from implementation, review, and testing. + +- Development answers: was the change built correctly? +- Review answers: is the code acceptable? +- Testing answers: does it behave technically as expected? +- Release answers: should this exact candidate move from one lane to another, and can we prove that it did? + +Release should usually be **human-initiated**, even if parts of the execution are automated. + +## Flow + +```mermaid +flowchart TD + A[Candidate ready in source lane] --> B{Human initiates promotion?} + B -- no --> A + B -- yes --> C[Promote candidate from source lane to target lane] + C --> D[Record candidate identity and promotion receipt] + D --> E[Run lane-specific verification] + E --> F{Acceptance decision} + F -- accept --> G[Record acceptance receipt] + G --> H[Candidate accepted in target lane] + F -- reject --> I[Invalidate candidate] + I --> J[Demotion or rollback path] + F -- refine --> K[Return to refinement or improvement] + F -- blocked --> L[Pause for human decision] +``` + +## Required concepts + +### 1. Lanes are project-defined + +Projects should define release lanes or environments structurally in config. + +Examples might be `dev`, `staging`, `production`, `local-current`, or something project-specific, but DevClaw core should not hardcode those names. + +### 2. Promotion is source to target + +Promotion should mean moving an exact candidate from one named lane to another named lane. + +A promotion request should at minimum identify: +- the candidate +- the source lane +- the target lane +- the promotion policy or type + +### 3. Candidate identity is mandatory + +A promoted candidate must be tied to an exact identity, such as: +- commit SHA +- PR URL +- branch +- tag, version, build id, or artifact id when relevant + +### 4. Proof of release is mandatory + +The release agent must prove that it released the intended version. + +Minimum proof should include: +- source candidate identity +- source lane +- target lane +- resulting target identity or target state +- verification evidence that the destination matches the intended candidate + +Core rule: + +> Prove source identity, prove destination identity, prove they match the intended promotion. + +### 5. Acceptance is candidate-specific + +Acceptance should apply to a specific promoted candidate, not the issue in general. + +Acceptance should record: +- who accepted it +- where it was accepted +- what evidence was used +- what exact candidate was accepted + +### 6. Acceptance defaults should be strong but configurable + +Suggested default acceptance criteria: +- candidate identity present +- source lane and target lane recorded +- proof of target state present +- required checks or evidence attached +- accepter identity recorded +- explicit outcome recorded + +Projects should be able to override: +- who can accept +- required evidence +- required checks +- allowed outcomes +- per-lane rules + +### 7. Acceptance outcomes should be explicit + +Suggested standard outcomes: +- `accept` +- `reject` +- `refine` +- `blocked` + +Rejecting acceptance should invalidate the candidate, not just vaguely reopen the issue. + +### 8. Rollback and demotion must be explicit + +If a promoted candidate fails acceptance or later validation, the system should explicitly mark it invalid and record the demotion or rollback path. + +### 9. Preconditions and repeat behavior must be defined + +The contract should define: +- what must already be true before promotion is allowed +- what should happen on repeated promotion attempts + - no-op + - retry + - replace candidate + - require explicit override + +## Config versus prompts + +This contract should live primarily in project config and workflow semantics, not only in prompts. + +Prompts can explain how a project uses the release agent, but they should not be the sole source of truth for: +- lane names +- allowed promotion paths +- acceptance authority +- required evidence +- lane-specific rules + +## Current implementation status + +Current DevClaw already provides: +- delivery phases for promotion and acceptance +- routing policies `human`, `agent`, and `skip` +- candidate provenance comments +- role-aware validation for promotion and acceptance states + +Current DevClaw does not yet fully provide: +- operator-defined lanes or environments in config +- source to target promotion semantics +- human-initiation UX as a first-class release start rule +- a strong proof-of-release schema +- shared default acceptance criteria with easy per-project overrides +- documented retry and idempotency behavior + +## Relationship to existing issues + +- `#216` root delivery-phase effort +- `#217` architect design guidance +- `#218` first-class delivery-phase implementation +- `#232` release-agent contract definition diff --git a/dev/runbooks/developing-devclaw-with-openclaw.md b/dev/runbooks/developing-devclaw-with-openclaw.md index 83915689..b2278cca 100644 --- a/dev/runbooks/developing-devclaw-with-openclaw.md +++ b/dev/runbooks/developing-devclaw-with-openclaw.md @@ -164,6 +164,12 @@ The point of the export is to publish local truth, not replace it. ## Promotion issue requirement +Generic release-agent contract and terminology for promotion, acceptance, proof of release, rollback, and operator initiation now live in: + +- `dev/design/release-agent-contract.md` + +Use that design doc as the generic model. This runbook remains the DevClaw-specific mapping of that model onto local lanes such as `devclaw-local-dev`, `devclaw-local-current`, live self-hosted validation, and upstream handoff. + Do not promote code to DevClaw official without a local issue that covers the full promotion from start to finish. That issue is not just "prep". It owns the entire promotion workflow. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 5380a086..39f5722d 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -84,7 +84,23 @@ roles: 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. +See **[Workflow Reference](WORKFLOW.md)** for the full state machine documentation, including state types, built-in actions, review policy options, how to enable the test phase, and the current delivery-phase contract. + +### Delivery configuration today versus planned contract + +Current workflow config can express: +- promotion policy (`skip`, `agent`, `human`) +- acceptance policy (`skip`, `agent`, `human`) +- the queue and active states used for those phases + +Current workflow config does **not** yet fully express the intended operator-facing release contract. In particular, the following are still a design target rather than a finished config surface: +- project-defined lane or environment names +- allowed source → target promotion paths +- shared default acceptance criteria +- required release evidence or proof receipts +- retry and override behavior for repeated promotions + +For the target contract being documented ahead of implementation, see [`../dev/design/release-agent-contract.md`](../dev/design/release-agent-contract.md). ### Timeouts diff --git a/docs/WORKFLOW.md b/docs/WORKFLOW.md index f63e4fcc..9916bb51 100644 --- a/docs/WORKFLOW.md +++ b/docs/WORKFLOW.md @@ -366,6 +366,53 @@ These prompts should instruct the tester to always call `task_comment` before `w --- +## Delivery Phases (optional) + +Delivery extends the workflow after technical review and optional testing. + +The current built-in phases are: +- **To Promote / Promoting** +- **To Accept / Accepting** + +These phases are intentionally about **candidate promotion** and **candidate acceptance**, not generic extra testing. + +### Important current rule + +Release should usually be **human-initiated**. A project may automate parts of release execution, but promotion should not be treated as automatic forward motion just because implementation, review, or testing completed. + +### Delivery flow shape + +```mermaid +flowchart TD + A[Candidate ready in source lane] --> B{Human initiates promotion?} + B -- no --> A + B -- yes --> C[Promote candidate from source lane to target lane] + C --> D[Record candidate identity and promotion receipt] + D --> E[Run lane-specific verification] + E --> F{Acceptance decision} + F -- accept --> G[Record acceptance receipt] + G --> H[Candidate accepted in target lane] + F -- reject --> I[Invalidate candidate] + I --> J[Demotion or rollback path] + F -- refine --> K[Return to refinement or improvement] + F -- blocked --> L[Pause for human decision] +``` + +### Current implementation versus target contract + +Current DevClaw provides the delivery-phase hooks, routing labels, and candidate-provenance plumbing. + +The full operator-facing release-agent contract still needs to be layered on top, especially for: +- project-defined lanes or environments +- allowed source → target promotion paths +- proof-of-release receipts +- shared default acceptance criteria with per-project overrides +- retry, repeat, and override behavior + +For the current design target, see [`dev/design/release-agent-contract.md`](../dev/design/release-agent-contract.md). + +--- + ## Customizing the Workflow ### Adding or Modifying States diff --git a/lib/tools/admin/workflow-guide.ts b/lib/tools/admin/workflow-guide.ts index b5ca1d91..6518483a 100644 --- a/lib/tools/admin/workflow-guide.ts +++ b/lib/tools/admin/workflow-guide.ts @@ -136,8 +136,14 @@ workflow: - \`activeState\` must point to an active state for the correct role. - Promotion should represent candidate promotion, not generic testing. - Acceptance should represent acceptance of the promoted candidate. +- Release should usually be human-initiated, even if parts of execution are automated. - Environment-specific deploy mechanics stay in project runbooks, not core workflow semantics. +## Current implementation versus target contract +- Today, workflow config covers delivery policies and the states they use. +- The broader release-agent contract still needs project-defined lanes/environments, allowed source → target promotion paths, proof-of-release receipts, shared acceptance defaults, and repeat/override behavior. +- See \`dev/design/release-agent-contract.md\` in the repo for the current design target. + ## Routing labels - Promotion uses \`promotion:human\`, \`promotion:agent\`, \`promotion:skip\` - Acceptance uses \`acceptance:human\`, \`acceptance:agent\`, \`acceptance:skip\` From 011b5b85cbfcca175d91d6fa0dc59d33099eb815 Mon Sep 17 00:00:00 2001 From: fujiwaranosai850 Date: Sat, 9 May 2026 05:20:49 +0000 Subject: [PATCH 06/30] docs: clarify release initiation policy --- dev/design/release-agent-contract.md | 2 +- docs/WORKFLOW.md | 2 +- lib/tools/admin/workflow-guide.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/dev/design/release-agent-contract.md b/dev/design/release-agent-contract.md index e822c5f0..ac914508 100644 --- a/dev/design/release-agent-contract.md +++ b/dev/design/release-agent-contract.md @@ -13,7 +13,7 @@ Release is a distinct process from implementation, review, and testing. - Testing answers: does it behave technically as expected? - Release answers: should this exact candidate move from one lane to another, and can we prove that it did? -Release should usually be **human-initiated**, even if parts of the execution are automated. +Release initiation should be **policy-controlled**, not automatic. Like PR handling, it may be human-initiated or agent-initiated depending on project policy. ## Flow diff --git a/docs/WORKFLOW.md b/docs/WORKFLOW.md index 9916bb51..0f3e8aab 100644 --- a/docs/WORKFLOW.md +++ b/docs/WORKFLOW.md @@ -378,7 +378,7 @@ These phases are intentionally about **candidate promotion** and **candidate acc ### Important current rule -Release should usually be **human-initiated**. A project may automate parts of release execution, but promotion should not be treated as automatic forward motion just because implementation, review, or testing completed. +Release initiation should be **policy-controlled**. Like PR handling, a project may choose human or agent initiation, but promotion should not be treated as automatic forward motion just because implementation, review, or testing completed. ### Delivery flow shape diff --git a/lib/tools/admin/workflow-guide.ts b/lib/tools/admin/workflow-guide.ts index 6518483a..440ef0ab 100644 --- a/lib/tools/admin/workflow-guide.ts +++ b/lib/tools/admin/workflow-guide.ts @@ -136,7 +136,7 @@ workflow: - \`activeState\` must point to an active state for the correct role. - Promotion should represent candidate promotion, not generic testing. - Acceptance should represent acceptance of the promoted candidate. -- Release should usually be human-initiated, even if parts of execution are automated. +- Release initiation should be policy-controlled, and may be human- or agent-initiated depending on project policy. - Environment-specific deploy mechanics stay in project runbooks, not core workflow semantics. ## Current implementation versus target contract From 05e7aef8ee31e97d0c470da6ee8d18ed394884b8 Mon Sep 17 00:00:00 2001 From: fujiwaranosai850 Date: Sat, 9 May 2026 05:35:19 +0000 Subject: [PATCH 07/30] docs: show agent-initiated promotion in release flow --- dev/design/release-agent-contract.md | 5 +++-- docs/WORKFLOW.md | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/dev/design/release-agent-contract.md b/dev/design/release-agent-contract.md index ac914508..f6905b96 100644 --- a/dev/design/release-agent-contract.md +++ b/dev/design/release-agent-contract.md @@ -19,9 +19,10 @@ Release initiation should be **policy-controlled**, not automatic. Like PR handl ```mermaid flowchart TD - A[Candidate ready in source lane] --> B{Human initiates promotion?} + A[Candidate ready in source lane] --> B{Promotion initiated by policy?} B -- no --> A - B -- yes --> C[Promote candidate from source lane to target lane] + B -- human --> C[Promote candidate from source lane to target lane] + B -- agent --> C C --> D[Record candidate identity and promotion receipt] D --> E[Run lane-specific verification] E --> F{Acceptance decision} diff --git a/docs/WORKFLOW.md b/docs/WORKFLOW.md index 0f3e8aab..9f73900d 100644 --- a/docs/WORKFLOW.md +++ b/docs/WORKFLOW.md @@ -384,9 +384,10 @@ Release initiation should be **policy-controlled**. Like PR handling, a project ```mermaid flowchart TD - A[Candidate ready in source lane] --> B{Human initiates promotion?} + A[Candidate ready in source lane] --> B{Promotion initiated by policy?} B -- no --> A - B -- yes --> C[Promote candidate from source lane to target lane] + B -- human --> C[Promote candidate from source lane to target lane] + B -- agent --> C C --> D[Record candidate identity and promotion receipt] D --> E[Run lane-specific verification] E --> F{Acceptance decision} From ba869c3af747843d58edafacc0723424f8df2e76 Mon Sep 17 00:00:00 2001 From: fujiwaranosai850 Date: Sat, 9 May 2026 05:45:37 +0000 Subject: [PATCH 08/30] docs: rewrite release docs as operator manual --- dev/design/release-agent-contract.md | 71 ++++++++++++---------------- docs/CONFIGURATION.md | 8 ++-- docs/WORKFLOW.md | 12 ++--- lib/tools/admin/workflow-guide.ts | 8 ++-- 4 files changed, 44 insertions(+), 55 deletions(-) diff --git a/dev/design/release-agent-contract.md b/dev/design/release-agent-contract.md index f6905b96..e995916f 100644 --- a/dev/design/release-agent-contract.md +++ b/dev/design/release-agent-contract.md @@ -1,8 +1,8 @@ # Release agent contract -This document describes the intended operator-facing contract for the DevClaw release agent. +This document describes the operator-facing contract for the DevClaw release agent. -It is a design target for the next implementation pass. The current codebase already supports delivery-phase hooks such as `To Promote`, `Promoting`, `To Accept`, and `Accepting`, but it does not yet fully implement the contract below. +Use it as the manual for how release promotion and acceptance are meant to work. ## Core idea @@ -38,15 +38,15 @@ flowchart TD ### 1. Lanes are project-defined -Projects should define release lanes or environments structurally in config. +Projects define release lanes or environments structurally in config. -Examples might be `dev`, `staging`, `production`, `local-current`, or something project-specific, but DevClaw core should not hardcode those names. +Examples might be `dev`, `staging`, `production`, `local-current`, or something project-specific, but DevClaw core does not hardcode those names. ### 2. Promotion is source to target -Promotion should mean moving an exact candidate from one named lane to another named lane. +Promotion means moving an exact candidate from one named lane to another named lane. -A promotion request should at minimum identify: +A promotion request identifies at minimum: - the candidate - the source lane - the target lane @@ -54,7 +54,7 @@ A promotion request should at minimum identify: ### 3. Candidate identity is mandatory -A promoted candidate must be tied to an exact identity, such as: +A promoted candidate is tied to an exact identity, such as: - commit SHA - PR URL - branch @@ -62,9 +62,9 @@ A promoted candidate must be tied to an exact identity, such as: ### 4. Proof of release is mandatory -The release agent must prove that it released the intended version. +The release agent proves that it released the intended version. -Minimum proof should include: +Minimum proof includes: - source candidate identity - source lane - target lane @@ -77,9 +77,9 @@ Core rule: ### 5. Acceptance is candidate-specific -Acceptance should apply to a specific promoted candidate, not the issue in general. +Acceptance applies to a specific promoted candidate, not the issue in general. -Acceptance should record: +Acceptance records: - who accepted it - where it was accepted - what evidence was used @@ -87,7 +87,7 @@ Acceptance should record: ### 6. Acceptance defaults should be strong but configurable -Suggested default acceptance criteria: +Default acceptance criteria: - candidate identity present - source lane and target lane recorded - proof of target state present @@ -95,7 +95,7 @@ Suggested default acceptance criteria: - accepter identity recorded - explicit outcome recorded -Projects should be able to override: +Projects can override: - who can accept - required evidence - required checks @@ -104,21 +104,21 @@ Projects should be able to override: ### 7. Acceptance outcomes should be explicit -Suggested standard outcomes: +Standard outcomes: - `accept` - `reject` - `refine` - `blocked` -Rejecting acceptance should invalidate the candidate, not just vaguely reopen the issue. +Rejecting acceptance invalidates the candidate, not just vaguely reopens the issue. ### 8. Rollback and demotion must be explicit -If a promoted candidate fails acceptance or later validation, the system should explicitly mark it invalid and record the demotion or rollback path. +If a promoted candidate fails acceptance or later validation, the system explicitly marks it invalid and records the demotion or rollback path. ### 9. Preconditions and repeat behavior must be defined -The contract should define: +The contract defines: - what must already be true before promotion is allowed - what should happen on repeated promotion attempts - no-op @@ -128,34 +128,23 @@ The contract should define: ## Config versus prompts -This contract should live primarily in project config and workflow semantics, not only in prompts. +This contract lives primarily in project config and workflow semantics, not only in prompts. -Prompts can explain how a project uses the release agent, but they should not be the sole source of truth for: +Prompts can explain how a project uses the release agent, but they are not the sole source of truth for: - lane names - allowed promotion paths - acceptance authority - required evidence - lane-specific rules -## Current implementation status - -Current DevClaw already provides: -- delivery phases for promotion and acceptance -- routing policies `human`, `agent`, and `skip` -- candidate provenance comments -- role-aware validation for promotion and acceptance states - -Current DevClaw does not yet fully provide: -- operator-defined lanes or environments in config -- source to target promotion semantics -- human-initiation UX as a first-class release start rule -- a strong proof-of-release schema -- shared default acceptance criteria with easy per-project overrides -- documented retry and idempotency behavior - -## Relationship to existing issues - -- `#216` root delivery-phase effort -- `#217` architect design guidance -- `#218` first-class delivery-phase implementation -- `#232` release-agent contract definition +## Operator checklist + +A usable release-agent project setup defines at least: +- release lanes or environments +- allowed promotion paths between lanes +- candidate identity requirements +- proof-of-release requirements +- acceptance authority and outcomes +- rollback or demotion behavior +- preconditions for promotion +- retry and override behavior for repeated promotions diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 39f5722d..cdf93371 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -86,21 +86,21 @@ The workflow section defines the state machine for issue lifecycle — states, t See **[Workflow Reference](WORKFLOW.md)** for the full state machine documentation, including state types, built-in actions, review policy options, how to enable the test phase, and the current delivery-phase contract. -### Delivery configuration today versus planned contract +### Release configuration -Current workflow config can express: +Workflow config expresses at minimum: - promotion policy (`skip`, `agent`, `human`) - acceptance policy (`skip`, `agent`, `human`) - the queue and active states used for those phases -Current workflow config does **not** yet fully express the intended operator-facing release contract. In particular, the following are still a design target rather than a finished config surface: +Release-agent configuration should also define: - project-defined lane or environment names - allowed source → target promotion paths - shared default acceptance criteria - required release evidence or proof receipts - retry and override behavior for repeated promotions -For the target contract being documented ahead of implementation, see [`../dev/design/release-agent-contract.md`](../dev/design/release-agent-contract.md). +For the operator-facing contract, see [`../dev/design/release-agent-contract.md`](../dev/design/release-agent-contract.md). ### Timeouts diff --git a/docs/WORKFLOW.md b/docs/WORKFLOW.md index 9f73900d..63924d6e 100644 --- a/docs/WORKFLOW.md +++ b/docs/WORKFLOW.md @@ -399,18 +399,18 @@ flowchart TD F -- blocked --> L[Pause for human decision] ``` -### Current implementation versus target contract +### Release-agent contract -Current DevClaw provides the delivery-phase hooks, routing labels, and candidate-provenance plumbing. +Delivery phases work together with the release-agent contract. -The full operator-facing release-agent contract still needs to be layered on top, especially for: -- project-defined lanes or environments +Projects define: +- lanes or environments - allowed source → target promotion paths - proof-of-release receipts -- shared default acceptance criteria with per-project overrides +- acceptance criteria and authority - retry, repeat, and override behavior -For the current design target, see [`dev/design/release-agent-contract.md`](../dev/design/release-agent-contract.md). +For the operator-facing contract, see [`dev/design/release-agent-contract.md`](../dev/design/release-agent-contract.md). --- diff --git a/lib/tools/admin/workflow-guide.ts b/lib/tools/admin/workflow-guide.ts index 440ef0ab..9b347427 100644 --- a/lib/tools/admin/workflow-guide.ts +++ b/lib/tools/admin/workflow-guide.ts @@ -139,10 +139,10 @@ workflow: - Release initiation should be policy-controlled, and may be human- or agent-initiated depending on project policy. - Environment-specific deploy mechanics stay in project runbooks, not core workflow semantics. -## Current implementation versus target contract -- Today, workflow config covers delivery policies and the states they use. -- The broader release-agent contract still needs project-defined lanes/environments, allowed source → target promotion paths, proof-of-release receipts, shared acceptance defaults, and repeat/override behavior. -- See \`dev/design/release-agent-contract.md\` in the repo for the current design target. +## Release-agent contract +- Workflow config covers delivery policies and the states they use. +- Release-agent config also defines project lanes or environments, allowed source → target promotion paths, proof-of-release receipts, shared acceptance defaults, and repeat or override behavior. +- See \`dev/design/release-agent-contract.md\` in the repo for the operator-facing contract. ## Routing labels - Promotion uses \`promotion:human\`, \`promotion:agent\`, \`promotion:skip\` From 205073e618c65e9fa1b904940afafa934c8978f2 Mon Sep 17 00:00:00 2001 From: fujiwaranosai850 Date: Sat, 9 May 2026 06:46:07 +0000 Subject: [PATCH 09/30] docs: add delivery controls to control layer doc --- docs/exploratory/CONTROL-LAYER.md | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/docs/exploratory/CONTROL-LAYER.md b/docs/exploratory/CONTROL-LAYER.md index 21fd33e1..500f339c 100644 --- a/docs/exploratory/CONTROL-LAYER.md +++ b/docs/exploratory/CONTROL-LAYER.md @@ -115,6 +115,13 @@ Three-layer merge: **built-in defaults → workspace yaml → project yaml**. Va | Setting | Default | Effect | |---|---|---| | `workflow.reviewPolicy` | `human` | `human` / `agent` / `auto` — controls review routing | +| `workflow.testPolicy` | `skip` | `skip` / `agent` — controls test routing | +| `workflow.delivery.promotion.policy` | `skip` | `skip` / `agent` / `human` — controls promotion routing | +| `workflow.delivery.acceptance.policy` | `skip` | `skip` / `agent` / `human` — controls acceptance routing | +| `workflow.delivery.promotion.queueState` | `toPromote` | Queue state used for promotion | +| `workflow.delivery.promotion.activeState` | `promoting` | Active state used for promotion | +| `workflow.delivery.acceptance.queueState` | `toAccept` | Queue state used for acceptance | +| `workflow.delivery.acceptance.activeState` | `accepting` | Active state used for acceptance | | `roles..models` | Registry defaults | Which model runs at each level | | `roles..levels` | Registry defaults | Available level names | | `roles..completionResults` | Registry defaults | Valid results for `work_finish` | @@ -131,7 +138,14 @@ Three-layer merge: **built-in defaults → workspace yaml → project yaml**. Va | `review:human` | Force human PR review | | `review:agent` | Force agent PR review | | `review:skip` | Skip review | +| `test:agent` | Route through tester phase | | `test:skip` | Skip test phase | +| `promotion:human` | Route promotion through human-controlled delivery pass | +| `promotion:agent` | Route promotion through agent reviewer pickup | +| `promotion:skip` | Skip promotion and advance on heartbeat | +| `acceptance:human` | Route acceptance through human-controlled delivery pass | +| `acceptance:agent` | Route acceptance through agent tester pickup | +| `acceptance:skip` | Skip acceptance and close on heartbeat | --- @@ -158,6 +172,18 @@ For issues in review states with `review:human` + eyes marker: - Merge conflict → To Improve - Merge failure → To Improve +### Delivery pass — promotion and acceptance routing + +For issues in delivery queue states: +- `promotion:agent` → reviewer pickup path (`To Promote` → `Promoting`) +- `promotion:skip` → heartbeat advances promotion without reviewer pickup +- `promotion:human` → heartbeat advances only when a current candidate record exists with status `active` +- `acceptance:agent` → tester pickup path (`To Accept` → `Accepting`) +- `acceptance:skip` → heartbeat marks the candidate `accepted`, advances, and closes per workflow +- `acceptance:human` → heartbeat advances only when a current candidate record exists with status `accepted` + +The delivery pass uses the configured promotion and acceptance queue states, reads per-issue routing labels, and performs deterministic label transitions plus close/reopen actions from the workflow statechart. + ### Tick pass — queue scanning Fills free worker slots by priority. Respects: one worker per role, sequential mode, maxPickupsPerTick (default 4), review/test skip labels. @@ -190,7 +216,10 @@ GitHub/GitLab settings that DevClaw reads but does not configure. | Can't finish with wrong role:result pair | Code (`isValidResult`) | No | | Can't run two workers of same role | Code (slot check) | No | | Review routing (human/agent/auto) | Code (computed label) | No | +| Test routing (`test:agent` / `test:skip`) | Code (computed label) | No | +| Delivery routing (`promotion:*`, `acceptance:*`) | Code (computed label) | No | | Auto-merge only for managed issues | Code (eyes reaction filter) | No | | Stale worker cleanup | Heartbeat (autonomous) | N/A | | PR approval detection | Heartbeat (autonomous) | N/A | +| Delivery-phase advancement for skip/human routes | Heartbeat (autonomous) | N/A | | Branch protection | GitHub/GitLab | N/A | From 70960f57638132896c4bc6176b7f03d1325f8cb3 Mon Sep 17 00:00:00 2001 From: fujiwaranosai850 Date: Sat, 9 May 2026 07:02:15 +0000 Subject: [PATCH 10/30] docs: clarify release prompt surfaces across operator docs --- README.md | 9 +++++++++ docs/ARCHITECTURE.md | 4 +++- docs/CONFIGURATION.md | 9 +++++++++ docs/ONBOARDING.md | 2 +- docs/REQUIREMENTS.md | 2 +- docs/WORKFLOW.md | 10 ++++++++++ docs/exploratory/CONTROL-LAYER.md | 2 ++ lib/tools/admin/workflow-guide.ts | 8 +++++++- 8 files changed, 42 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index e511c4b0..75c948fa 100644 --- a/README.md +++ b/README.md @@ -371,6 +371,7 @@ devclaw/ ├── workflow.yaml (workspace-level workflow overrides) ├── prompts/ (workspace defaults — fallback) │ ├── developer.md +│ ├── reviewer.md │ ├── tester.md │ └── architect.md └── projects/ @@ -378,15 +379,23 @@ devclaw/ │ ├── workflow.yaml (project-specific workflow overrides) │ └── prompts/ │ ├── developer.md "Run npm test before committing. Deploy URL: staging.example.com" + │ ├── reviewer.md "Promotion review rules. Required evidence for candidate signoff." │ └── tester.md "Check OAuth flow. Verify mobile responsiveness." └── my-api/ └── prompts/ ├── developer.md "Run cargo test. Follow REST conventions in CONTRIBUTING.md" + ├── reviewer.md "Review API changes and promotion evidence." └── tester.md "Verify all endpoints return correct status codes." ``` Deployment steps, test commands, coding standards, acceptance criteria — all injected at dispatch time, per project, per role. +There is no separate `release-agent.md` prompt file today. Delivery phases reuse existing worker roles: +- promotion / `To Promote` / `Promoting` use the **reviewer** prompt +- acceptance / `To Accept` / `Accepting` use the **tester** prompt + +Release policy and lane semantics belong in workflow/config and runbooks, not only in prompts. + --- ## The orchestrator's role diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 47ff3876..3adb8fe5 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -546,6 +546,8 @@ sequenceDiagram The source path is logged for production traceability: `Bootstrap hook: injected developer instructions for project "my-app" from /path/to/prompts/developer.md`. +There is no dedicated release-agent prompt file. Promotion uses the reviewer role prompt, and acceptance uses the tester role prompt. + ## Data flow map Every piece of data and where it lives: @@ -757,7 +759,7 @@ See [CONFIGURATION.md](CONFIGURATION.md) for the full reference. | Worker state | `/devclaw/projects.json` | Per-project worker state | | Workflow config (workspace) | `/devclaw/workflow.yaml` | Workspace-level role/workflow overrides | | Workflow config (project) | `/devclaw/projects//workflow.yaml` | Project-specific overrides | -| Default role instructions | `/devclaw/prompts/.md` | Default `developer.md`, `tester.md`, `architect.md` | +| Default role instructions | `/devclaw/prompts/.md` | Default `developer.md`, `reviewer.md`, `tester.md`, `architect.md` | | Project role instructions | `/devclaw/projects//prompts/.md` | Per-project role instruction overrides | | Audit log | `/devclaw/log/audit.log` | NDJSON event log | | Session transcripts | `~/.openclaw/agents//sessions/.jsonl` | Conversation history per session | diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index cdf93371..a3a9caa7 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -378,6 +378,7 @@ Each role in the `workers` record has a `WorkerState` object: │ ├── workflow.yaml ← Workspace-level config overrides │ ├── prompts/ │ │ ├── developer.md ← Default developer instructions +│ │ ├── reviewer.md ← Default reviewer instructions │ │ ├── tester.md ← Default tester instructions │ │ └── architect.md ← Default architect instructions │ ├── projects/ @@ -385,11 +386,13 @@ Each role in the `workers` record has a `WorkerState` object: │ │ │ ├── workflow.yaml ← Project-specific config overrides │ │ │ └── prompts/ │ │ │ ├── developer.md ← Project-specific developer instructions +│ │ │ ├── reviewer.md ← Project-specific reviewer instructions │ │ │ ├── tester.md ← Project-specific tester instructions │ │ │ └── architect.md ← Project-specific architect instructions │ │ └── another-project/ │ │ └── prompts/ │ │ ├── developer.md +│ │ ├── reviewer.md │ │ └── tester.md │ └── log/ │ └── audit.log ← NDJSON event log (auto-managed) @@ -403,6 +406,12 @@ Role instructions are injected into worker sessions via the `agent:bootstrap` ho Edit to customize: deployment steps, test commands, acceptance criteria, coding standards. +There is no separate `release-agent.md` prompt file in the current system. Delivery phases reuse existing worker roles: +- promotion uses `reviewer.md` +- acceptance uses `tester.md` + +Release lanes, routing policy, and proof requirements belong in workflow/config and runbooks, not only in prompt text. + **Source:** [`lib/dispatch/bootstrap-hook.ts`](../lib/dispatch/bootstrap-hook.ts) --- diff --git a/docs/ONBOARDING.md b/docs/ONBOARDING.md index 21f0660f..bf9dd6cc 100644 --- a/docs/ONBOARDING.md +++ b/docs/ONBOARDING.md @@ -155,7 +155,7 @@ Go to the Telegram/WhatsApp group for the project and tell the orchestrator agen The agent calls `project_register`, which atomically: - Validates the repo and auto-detects GitHub/GitLab from remote - Creates all state labels (idempotent) -- Scaffolds role instruction files (`devclaw/projects//prompts/developer.md`, `tester.md`, `architect.md`) +- Scaffolds role instruction files (`devclaw/projects//prompts/developer.md`, `reviewer.md`, `tester.md`, `architect.md`) - Adds the project entry to `projects.json` - Logs the registration event diff --git a/docs/REQUIREMENTS.md b/docs/REQUIREMENTS.md index ff037e6e..f6418ab2 100644 --- a/docs/REQUIREMENTS.md +++ b/docs/REQUIREMENTS.md @@ -30,7 +30,7 @@ DevClaw orchestrates AI development agents across GitHub and GitLab projects. Th **Layer 2 — Workflow State Machine** controls _when_ work moves. Label-driven state transitions on the issue tracker. Heartbeat scans queues, dispatches workers, handles PR lifecycle. Configured per-workspace or per-project in `workflow.yaml`. -**Layer 3 — Role Prompts** controls _how_ work is done. System-level instructions injected into worker sessions via the bootstrap hook. Per-role (`developer.md`, `tester.md`) and per-project overrides. +**Layer 3 — Role Prompts** controls _how_ work is done. System-level instructions injected into worker sessions via the bootstrap hook. Per-role (`developer.md`, `reviewer.md`, `tester.md`, `architect.md`) and per-project overrides. Delivery phases reuse reviewer and tester prompts rather than a separate release-agent prompt. **Layer 4 — Task Instructions** controls _what_ work is done. Built from issue description, comments, PR feedback, attachments. Constructed fresh on each dispatch. Includes mandatory completion instructions (`work_finish` call). diff --git a/docs/WORKFLOW.md b/docs/WORKFLOW.md index 63924d6e..0c62b889 100644 --- a/docs/WORKFLOW.md +++ b/docs/WORKFLOW.md @@ -412,6 +412,16 @@ Projects define: For the operator-facing contract, see [`dev/design/release-agent-contract.md`](../dev/design/release-agent-contract.md). +### Prompt files for delivery phases + +There is no separate `release-agent.md` prompt file today. + +Delivery phases reuse existing worker roles: +- **promotion** uses the reviewer role and therefore `prompts/reviewer.md` +- **acceptance** uses the tester role and therefore `prompts/tester.md` + +Put role-specific execution instructions in those prompt files. Put lane definitions, routing policy, and release proof requirements in workflow/config and project runbooks. + --- ## Customizing the Workflow diff --git a/docs/exploratory/CONTROL-LAYER.md b/docs/exploratory/CONTROL-LAYER.md index 500f339c..248bd64a 100644 --- a/docs/exploratory/CONTROL-LAYER.md +++ b/docs/exploratory/CONTROL-LAYER.md @@ -41,6 +41,8 @@ Role prompts are resolved per-project with fallback: 1. `devclaw/projects//prompts/.md` 2. `devclaw/prompts/.md` +There is no separate release-agent prompt file. Promotion uses the reviewer prompt. Acceptance uses the tester prompt. + ### What can go wrong - Architect calls `work_finish(done)` without creating a task — **no code guard** diff --git a/lib/tools/admin/workflow-guide.ts b/lib/tools/admin/workflow-guide.ts index 9b347427..bad467f4 100644 --- a/lib/tools/admin/workflow-guide.ts +++ b/lib/tools/admin/workflow-guide.ts @@ -314,7 +314,13 @@ Each role can have a system prompt file: - Workspace default: \`/prompts/.md\` - Project override: \`/projects//prompts/.md\` -If a role has no prompt file, the worker gets a generic system prompt. When enabling a new role (like tester), create its prompt file.`; +If a role has no prompt file, the worker gets a generic system prompt. When enabling a new role (like tester), create its prompt file. + +There is no separate release-agent prompt file today. Delivery phases reuse existing roles: +- promotion uses the reviewer prompt +- acceptance uses the tester prompt + +Keep release lanes, routing policy, and proof requirements in workflow/config and runbooks, not only in prompt text.`; } function buildReviewSection(): string { From 0e28ae5a4f2d49cb1221eeab486463cb4d9c22df Mon Sep 17 00:00:00 2001 From: fujiwaranosai850 Date: Sat, 9 May 2026 07:07:15 +0000 Subject: [PATCH 11/30] docs: finish prompt-surface sweep across markdown docs --- README.md | 6 +++--- defaults/AGENTS.md | 2 +- docs/WORKFLOW.md | 8 ++++++++ 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 75c948fa..64c01707 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ Each project is fully isolated — own queue, workers, sessions, and state. Work - **[Scheduling engine](#automatic-scheduling)** — `work_heartbeat` continuously scans queues, dispatches workers, and drives DEV → review → DEV [feedback loops](#how-tasks-flow-between-roles) - **[Project isolation](#execution-modes)** — parallel workers per project, parallel projects across the system -- **[Role instructions](#custom-instructions-per-project)** — per-project, per-role prompts injected at dispatch time +- **[Role instructions](#custom-instructions-per-project)** — per-project, per-role prompts injected via the bootstrap hook ### Process enforcement @@ -364,7 +364,7 @@ Workers can also comment during work — QA leaves review feedback, DEV posts im ### Custom instructions per project -Each project gets instruction files that workers receive with every task they pick up: +Each project gets instruction files that worker sessions load via the `agent:bootstrap` hook: ``` devclaw/ @@ -388,7 +388,7 @@ devclaw/ └── tester.md "Verify all endpoints return correct status codes." ``` -Deployment steps, test commands, coding standards, acceptance criteria — all injected at dispatch time, per project, per role. +Deployment steps, test commands, coding standards, acceptance criteria — all injected into worker sessions from these role prompt files. There is no separate `release-agent.md` prompt file today. Delivery phases reuse existing worker roles: - promotion / `To Promote` / `Promoting` use the **reviewer** prompt diff --git a/defaults/AGENTS.md b/defaults/AGENTS.md index 8c70881f..32ff9d16 100644 --- a/defaults/AGENTS.md +++ b/defaults/AGENTS.md @@ -135,7 +135,7 @@ If the test phase is enabled in workflow.yaml: ### Prompt Instructions -Workers receive role-specific instructions appended to their task message. These are loaded from `devclaw/projects//prompts/.md` in the workspace, falling back to `devclaw/prompts/.md` if no project-specific file exists. `project_register` scaffolds these files automatically — edit them to customize worker behavior per project. +Workers receive role-specific instructions via the bootstrap hook, not by appending them to the task message. These are loaded from `devclaw/projects//prompts/.md` in the workspace, falling back to `devclaw/prompts/.md` if no project-specific file exists. `project_register` scaffolds these files automatically — edit them to customize worker behavior per project. ### Heartbeats diff --git a/docs/WORKFLOW.md b/docs/WORKFLOW.md index 0c62b889..a15fcbbe 100644 --- a/docs/WORKFLOW.md +++ b/docs/WORKFLOW.md @@ -196,6 +196,14 @@ Override the project-level policy for a single issue using labels: **Source:** [`lib/workflow/queries.ts`](../lib/workflow/queries.ts) — `resolveReviewRouting()` +### Reviewer Prompt Configuration + +Agent review uses the reviewer role prompt files: +- Default: `devclaw/prompts/reviewer.md` +- Per-project: `devclaw/projects//prompts/reviewer.md` + +The same reviewer prompt also governs agent-driven promotion work in `To Promote` / `Promoting`. + --- ## Test Phase (optional) From 10075c0c2e1f2d47835eb51e8413e317084e9d9e Mon Sep 17 00:00:00 2001 From: fujiwaranosai850 Date: Sat, 9 May 2026 07:23:59 +0000 Subject: [PATCH 12/30] docs: define release.md as target release prompt surface --- README.md | 6 ++---- dev/design/release-agent-contract.md | 6 ++++++ docs/ARCHITECTURE.md | 2 +- docs/CONFIGURATION.md | 4 +--- docs/REQUIREMENTS.md | 2 +- docs/WORKFLOW.md | 8 ++------ docs/exploratory/CONTROL-LAYER.md | 2 +- lib/tools/admin/workflow-guide.ts | 4 +--- 8 files changed, 15 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 64c01707..1d0147c8 100644 --- a/README.md +++ b/README.md @@ -390,11 +390,9 @@ devclaw/ Deployment steps, test commands, coding standards, acceptance criteria — all injected into worker sessions from these role prompt files. -There is no separate `release-agent.md` prompt file today. Delivery phases reuse existing worker roles: -- promotion / `To Promote` / `Promoting` use the **reviewer** prompt -- acceptance / `To Accept` / `Accepting` use the **tester** prompt +Release-agent design should use a dedicated `release.md` prompt surface rather than treating reviewer/tester prompt reuse as the intended contract. -Release policy and lane semantics belong in workflow/config and runbooks, not only in prompts. +Release policy, lane semantics, and proof requirements still belong in workflow/config and runbooks, not only in prompts. --- diff --git a/dev/design/release-agent-contract.md b/dev/design/release-agent-contract.md index e995916f..c2f8617e 100644 --- a/dev/design/release-agent-contract.md +++ b/dev/design/release-agent-contract.md @@ -52,6 +52,12 @@ A promotion request identifies at minimum: - the target lane - the promotion policy or type +### Prompt surface + +Release-agent design uses a dedicated `release.md` prompt surface. + +That prompt is where release-execution behavior belongs. It is not the source of truth for lanes, routing policy, allowed promotion paths, or proof requirements. Those remain structural workflow/config and runbook concerns. + ### 3. Candidate identity is mandatory A promoted candidate is tied to an exact identity, such as: diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3adb8fe5..3f9f5f4a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -546,7 +546,7 @@ sequenceDiagram The source path is logged for production traceability: `Bootstrap hook: injected developer instructions for project "my-app" from /path/to/prompts/developer.md`. -There is no dedicated release-agent prompt file. Promotion uses the reviewer role prompt, and acceptance uses the tester role prompt. +Release-agent design should add a dedicated `release.md` prompt surface rather than making reviewer/tester prompt reuse the intended contract. ## Data flow map diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index a3a9caa7..df9ffb6f 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -406,9 +406,7 @@ Role instructions are injected into worker sessions via the `agent:bootstrap` ho Edit to customize: deployment steps, test commands, acceptance criteria, coding standards. -There is no separate `release-agent.md` prompt file in the current system. Delivery phases reuse existing worker roles: -- promotion uses `reviewer.md` -- acceptance uses `tester.md` +Release-agent design should introduce a dedicated `release.md` prompt surface. Release lanes, routing policy, and proof requirements belong in workflow/config and runbooks, not only in prompt text. diff --git a/docs/REQUIREMENTS.md b/docs/REQUIREMENTS.md index f6418ab2..cb7c6950 100644 --- a/docs/REQUIREMENTS.md +++ b/docs/REQUIREMENTS.md @@ -30,7 +30,7 @@ DevClaw orchestrates AI development agents across GitHub and GitLab projects. Th **Layer 2 — Workflow State Machine** controls _when_ work moves. Label-driven state transitions on the issue tracker. Heartbeat scans queues, dispatches workers, handles PR lifecycle. Configured per-workspace or per-project in `workflow.yaml`. -**Layer 3 — Role Prompts** controls _how_ work is done. System-level instructions injected into worker sessions via the bootstrap hook. Per-role (`developer.md`, `reviewer.md`, `tester.md`, `architect.md`) and per-project overrides. Delivery phases reuse reviewer and tester prompts rather than a separate release-agent prompt. +**Layer 3 — Role Prompts** controls _how_ work is done. System-level instructions injected into worker sessions via the bootstrap hook. Per-role (`developer.md`, `reviewer.md`, `tester.md`, `architect.md`) and per-project overrides. Release-agent design should add a dedicated `release.md` prompt surface. **Layer 4 — Task Instructions** controls _what_ work is done. Built from issue description, comments, PR feedback, attachments. Constructed fresh on each dispatch. Includes mandatory completion instructions (`work_finish` call). diff --git a/docs/WORKFLOW.md b/docs/WORKFLOW.md index a15fcbbe..489bb97e 100644 --- a/docs/WORKFLOW.md +++ b/docs/WORKFLOW.md @@ -422,13 +422,9 @@ For the operator-facing contract, see [`dev/design/release-agent-contract.md`](. ### Prompt files for delivery phases -There is no separate `release-agent.md` prompt file today. +The design direction is a dedicated `release.md` prompt surface for release work. -Delivery phases reuse existing worker roles: -- **promotion** uses the reviewer role and therefore `prompts/reviewer.md` -- **acceptance** uses the tester role and therefore `prompts/tester.md` - -Put role-specific execution instructions in those prompt files. Put lane definitions, routing policy, and release proof requirements in workflow/config and project runbooks. +Put release-execution instructions in that prompt surface. Put lane definitions, routing policy, and release proof requirements in workflow/config and project runbooks. --- diff --git a/docs/exploratory/CONTROL-LAYER.md b/docs/exploratory/CONTROL-LAYER.md index 248bd64a..1dcae82b 100644 --- a/docs/exploratory/CONTROL-LAYER.md +++ b/docs/exploratory/CONTROL-LAYER.md @@ -41,7 +41,7 @@ Role prompts are resolved per-project with fallback: 1. `devclaw/projects//prompts/.md` 2. `devclaw/prompts/.md` -There is no separate release-agent prompt file. Promotion uses the reviewer prompt. Acceptance uses the tester prompt. +Release-agent design should add a dedicated `release.md` prompt surface. ### What can go wrong diff --git a/lib/tools/admin/workflow-guide.ts b/lib/tools/admin/workflow-guide.ts index bad467f4..a413e23f 100644 --- a/lib/tools/admin/workflow-guide.ts +++ b/lib/tools/admin/workflow-guide.ts @@ -316,9 +316,7 @@ Each role can have a system prompt file: If a role has no prompt file, the worker gets a generic system prompt. When enabling a new role (like tester), create its prompt file. -There is no separate release-agent prompt file today. Delivery phases reuse existing roles: -- promotion uses the reviewer prompt -- acceptance uses the tester prompt +Release-agent design should introduce a dedicated \`release.md\` prompt surface. Keep release lanes, routing policy, and proof requirements in workflow/config and runbooks, not only in prompt text.`; } From dfc9f44c3754d27d09a51f17d3b344360aadfe3f Mon Sep 17 00:00:00 2001 From: fujiwaranosai850 Date: Sat, 9 May 2026 07:29:35 +0000 Subject: [PATCH 13/30] docs: add release.md to documented prompt trees --- README.md | 15 +++++++++------ dev/design/release-agent-contract.md | 2 +- docs/ARCHITECTURE.md | 4 ++-- docs/CONFIGURATION.md | 9 ++++++--- docs/ONBOARDING.md | 2 +- docs/REQUIREMENTS.md | 2 +- docs/WORKFLOW.md | 6 +++--- docs/exploratory/CONTROL-LAYER.md | 3 ++- lib/tools/admin/workflow-guide.ts | 4 ++-- 9 files changed, 27 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 1d0147c8..6d1fc44f 100644 --- a/README.md +++ b/README.md @@ -373,24 +373,27 @@ devclaw/ │ ├── developer.md │ ├── reviewer.md │ ├── tester.md +│ ├── release.md │ └── architect.md └── projects/ ├── my-webapp/ │ ├── workflow.yaml (project-specific workflow overrides) │ └── prompts/ │ ├── developer.md "Run npm test before committing. Deploy URL: staging.example.com" - │ ├── reviewer.md "Promotion review rules. Required evidence for candidate signoff." - │ └── tester.md "Check OAuth flow. Verify mobile responsiveness." + │ ├── reviewer.md "Code review rules and PR acceptance policy." + │ ├── tester.md "Check OAuth flow. Verify mobile responsiveness." + │ └── release.md "Promotion steps, lane checks, proof-of-release requirements." └── my-api/ └── prompts/ ├── developer.md "Run cargo test. Follow REST conventions in CONTRIBUTING.md" - ├── reviewer.md "Review API changes and promotion evidence." - └── tester.md "Verify all endpoints return correct status codes." + ├── reviewer.md "Review API changes and PR quality." + ├── tester.md "Verify all endpoints return correct status codes." + └── release.md "Promote approved builds between lanes and record evidence." ``` -Deployment steps, test commands, coding standards, acceptance criteria — all injected into worker sessions from these role prompt files. +Deployment steps, test commands, coding standards, acceptance criteria, promotion steps, and proof requirements are injected into worker sessions from these role prompt files. -Release-agent design should use a dedicated `release.md` prompt surface rather than treating reviewer/tester prompt reuse as the intended contract. +Release work uses `release.md` as its dedicated prompt surface. Release policy, lane semantics, and proof requirements still belong in workflow/config and runbooks, not only in prompts. diff --git a/dev/design/release-agent-contract.md b/dev/design/release-agent-contract.md index c2f8617e..ff54517b 100644 --- a/dev/design/release-agent-contract.md +++ b/dev/design/release-agent-contract.md @@ -54,7 +54,7 @@ A promotion request identifies at minimum: ### Prompt surface -Release-agent design uses a dedicated `release.md` prompt surface. +Release work uses a dedicated `release.md` prompt surface. That prompt is where release-execution behavior belongs. It is not the source of truth for lanes, routing policy, allowed promotion paths, or proof requirements. Those remain structural workflow/config and runbook concerns. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3f9f5f4a..04bdd9f2 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -546,7 +546,7 @@ sequenceDiagram The source path is logged for production traceability: `Bootstrap hook: injected developer instructions for project "my-app" from /path/to/prompts/developer.md`. -Release-agent design should add a dedicated `release.md` prompt surface rather than making reviewer/tester prompt reuse the intended contract. +Release work uses a dedicated `release.md` prompt surface. ## Data flow map @@ -759,7 +759,7 @@ See [CONFIGURATION.md](CONFIGURATION.md) for the full reference. | Worker state | `/devclaw/projects.json` | Per-project worker state | | Workflow config (workspace) | `/devclaw/workflow.yaml` | Workspace-level role/workflow overrides | | Workflow config (project) | `/devclaw/projects//workflow.yaml` | Project-specific overrides | -| Default role instructions | `/devclaw/prompts/.md` | Default `developer.md`, `reviewer.md`, `tester.md`, `architect.md` | +| Default role instructions | `/devclaw/prompts/.md` | Default `developer.md`, `reviewer.md`, `tester.md`, `release.md`, `architect.md` | | Project role instructions | `/devclaw/projects//prompts/.md` | Per-project role instruction overrides | | Audit log | `/devclaw/log/audit.log` | NDJSON event log | | Session transcripts | `~/.openclaw/agents//sessions/.jsonl` | Conversation history per session | diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index df9ffb6f..a8f70bc1 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -380,6 +380,7 @@ Each role in the `workers` record has a `WorkerState` object: │ │ ├── developer.md ← Default developer instructions │ │ ├── reviewer.md ← Default reviewer instructions │ │ ├── tester.md ← Default tester instructions +│ │ ├── release.md ← Default release instructions │ │ └── architect.md ← Default architect instructions │ ├── projects/ │ │ ├── my-webapp/ @@ -388,12 +389,14 @@ Each role in the `workers` record has a `WorkerState` object: │ │ │ ├── developer.md ← Project-specific developer instructions │ │ │ ├── reviewer.md ← Project-specific reviewer instructions │ │ │ ├── tester.md ← Project-specific tester instructions +│ │ │ ├── release.md ← Project-specific release instructions │ │ │ └── architect.md ← Project-specific architect instructions │ │ └── another-project/ │ │ └── prompts/ │ │ ├── developer.md │ │ ├── reviewer.md -│ │ └── tester.md +│ │ ├── tester.md +│ │ └── release.md │ └── log/ │ └── audit.log ← NDJSON event log (auto-managed) ├── AGENTS.md ← Agent identity documentation @@ -404,9 +407,9 @@ Each role in the `workers` record has a `WorkerState` object: Role instructions are injected into worker sessions via the `agent:bootstrap` hook at session startup. The hook loads instructions from `devclaw/projects//prompts/.md`, falling back to `devclaw/prompts/.md`. -Edit to customize: deployment steps, test commands, acceptance criteria, coding standards. +Edit to customize: deployment steps, test commands, acceptance criteria, coding standards, promotion steps, and proof-of-release behavior. -Release-agent design should introduce a dedicated `release.md` prompt surface. +Release work uses `release.md` as its dedicated prompt surface. Release lanes, routing policy, and proof requirements belong in workflow/config and runbooks, not only in prompt text. diff --git a/docs/ONBOARDING.md b/docs/ONBOARDING.md index bf9dd6cc..d6fbff91 100644 --- a/docs/ONBOARDING.md +++ b/docs/ONBOARDING.md @@ -155,7 +155,7 @@ Go to the Telegram/WhatsApp group for the project and tell the orchestrator agen The agent calls `project_register`, which atomically: - Validates the repo and auto-detects GitHub/GitLab from remote - Creates all state labels (idempotent) -- Scaffolds role instruction files (`devclaw/projects//prompts/developer.md`, `reviewer.md`, `tester.md`, `architect.md`) +- Scaffolds role instruction files (`devclaw/projects//prompts/developer.md`, `reviewer.md`, `tester.md`, `release.md`, `architect.md`) - Adds the project entry to `projects.json` - Logs the registration event diff --git a/docs/REQUIREMENTS.md b/docs/REQUIREMENTS.md index cb7c6950..564a96b5 100644 --- a/docs/REQUIREMENTS.md +++ b/docs/REQUIREMENTS.md @@ -30,7 +30,7 @@ DevClaw orchestrates AI development agents across GitHub and GitLab projects. Th **Layer 2 — Workflow State Machine** controls _when_ work moves. Label-driven state transitions on the issue tracker. Heartbeat scans queues, dispatches workers, handles PR lifecycle. Configured per-workspace or per-project in `workflow.yaml`. -**Layer 3 — Role Prompts** controls _how_ work is done. System-level instructions injected into worker sessions via the bootstrap hook. Per-role (`developer.md`, `reviewer.md`, `tester.md`, `architect.md`) and per-project overrides. Release-agent design should add a dedicated `release.md` prompt surface. +**Layer 3 — Role Prompts** controls _how_ work is done. System-level instructions injected into worker sessions via the bootstrap hook. Per-role (`developer.md`, `reviewer.md`, `tester.md`, `release.md`, `architect.md`) and per-project overrides. Release work uses `release.md` as its dedicated prompt surface. **Layer 4 — Task Instructions** controls _what_ work is done. Built from issue description, comments, PR feedback, attachments. Constructed fresh on each dispatch. Includes mandatory completion instructions (`work_finish` call). diff --git a/docs/WORKFLOW.md b/docs/WORKFLOW.md index 489bb97e..51c5b5a3 100644 --- a/docs/WORKFLOW.md +++ b/docs/WORKFLOW.md @@ -202,8 +202,6 @@ Agent review uses the reviewer role prompt files: - Default: `devclaw/prompts/reviewer.md` - Per-project: `devclaw/projects//prompts/reviewer.md` -The same reviewer prompt also governs agent-driven promotion work in `To Promote` / `Promoting`. - --- ## Test Phase (optional) @@ -422,7 +420,9 @@ For the operator-facing contract, see [`dev/design/release-agent-contract.md`](. ### Prompt files for delivery phases -The design direction is a dedicated `release.md` prompt surface for release work. +Release work uses a dedicated `release.md` prompt surface: +- Default: `devclaw/prompts/release.md` +- Per-project: `devclaw/projects//prompts/release.md` Put release-execution instructions in that prompt surface. Put lane definitions, routing policy, and release proof requirements in workflow/config and project runbooks. diff --git a/docs/exploratory/CONTROL-LAYER.md b/docs/exploratory/CONTROL-LAYER.md index 1dcae82b..96bda6fa 100644 --- a/docs/exploratory/CONTROL-LAYER.md +++ b/docs/exploratory/CONTROL-LAYER.md @@ -31,6 +31,7 @@ Instructions injected into the LLM context. The agent *should* follow them but * | `devclaw/prompts/developer.md` | Bootstrap hook → `WORKER_INSTRUCTIONS.md` | Work in worktrees, don't merge PR, no closing keywords in PR description | | `devclaw/prompts/reviewer.md` | Bootstrap hook → `WORKER_INSTRUCTIONS.md` | Review diff only, call task_comment first, then approve/reject | | `devclaw/prompts/tester.md` | Bootstrap hook → `WORKER_INSTRUCTIONS.md` | Run tests, always call task_comment with findings | +| `devclaw/prompts/release.md` | Bootstrap hook → `WORKER_INSTRUCTIONS.md` | Promotion steps, lane checks, release evidence, rollback handling | | `AGENTS.md` | Workspace context file | Orchestrator must never write code, priority ordering, tool restrictions | | `SOUL.md` / `IDENTITY.md` | Workspace context file | Personality, communication style | | `buildTaskMessage()` | Appended to task message | Mandatory completion block: "you MUST call work_finish" with valid results | @@ -41,7 +42,7 @@ Role prompts are resolved per-project with fallback: 1. `devclaw/projects//prompts/.md` 2. `devclaw/prompts/.md` -Release-agent design should add a dedicated `release.md` prompt surface. +Release work uses `release.md` as its dedicated prompt surface. ### What can go wrong diff --git a/lib/tools/admin/workflow-guide.ts b/lib/tools/admin/workflow-guide.ts index a413e23f..49d6f86d 100644 --- a/lib/tools/admin/workflow-guide.ts +++ b/lib/tools/admin/workflow-guide.ts @@ -314,9 +314,9 @@ Each role can have a system prompt file: - Workspace default: \`/prompts/.md\` - Project override: \`/projects//prompts/.md\` -If a role has no prompt file, the worker gets a generic system prompt. When enabling a new role (like tester), create its prompt file. +If a role has no prompt file, the worker gets a generic system prompt. When enabling a new role (like tester or release), create its prompt file. -Release-agent design should introduce a dedicated \`release.md\` prompt surface. +Release work uses a dedicated \`release.md\` prompt surface. Keep release lanes, routing policy, and proof requirements in workflow/config and runbooks, not only in prompt text.`; } From a158c431bb6bac25da57e3557cca838b2add2b54 Mon Sep 17 00:00:00 2001 From: fujiwaranosai850 Date: Sat, 9 May 2026 07:33:24 +0000 Subject: [PATCH 14/30] docs: rename release agent to Deployer --- README.md | 8 ++++---- ...elease-agent-contract.md => deployer-contract.md} | 12 ++++++------ dev/runbooks/developing-devclaw-with-openclaw.md | 4 ++-- docs/ARCHITECTURE.md | 4 ++-- docs/CONFIGURATION.md | 10 +++++----- docs/ONBOARDING.md | 2 +- docs/REQUIREMENTS.md | 2 +- docs/WORKFLOW.md | 10 +++++----- docs/exploratory/CONTROL-LAYER.md | 4 ++-- lib/tools/admin/workflow-guide.ts | 4 ++-- 10 files changed, 30 insertions(+), 30 deletions(-) rename dev/design/{release-agent-contract.md => deployer-contract.md} (93%) diff --git a/README.md b/README.md index 6d1fc44f..77fd90d9 100644 --- a/README.md +++ b/README.md @@ -373,7 +373,7 @@ devclaw/ │ ├── developer.md │ ├── reviewer.md │ ├── tester.md -│ ├── release.md +│ ├── deployer.md │ └── architect.md └── projects/ ├── my-webapp/ @@ -382,18 +382,18 @@ devclaw/ │ ├── developer.md "Run npm test before committing. Deploy URL: staging.example.com" │ ├── reviewer.md "Code review rules and PR acceptance policy." │ ├── tester.md "Check OAuth flow. Verify mobile responsiveness." - │ └── release.md "Promotion steps, lane checks, proof-of-release requirements." + │ └── deployer.md "Promotion steps, lane checks, proof-of-release requirements." └── my-api/ └── prompts/ ├── developer.md "Run cargo test. Follow REST conventions in CONTRIBUTING.md" ├── reviewer.md "Review API changes and PR quality." ├── tester.md "Verify all endpoints return correct status codes." - └── release.md "Promote approved builds between lanes and record evidence." + └── deployer.md "Promote approved builds between lanes and record evidence." ``` Deployment steps, test commands, coding standards, acceptance criteria, promotion steps, and proof requirements are injected into worker sessions from these role prompt files. -Release work uses `release.md` as its dedicated prompt surface. +The Deployer uses `deployer.md` as its dedicated prompt surface. Release policy, lane semantics, and proof requirements still belong in workflow/config and runbooks, not only in prompts. diff --git a/dev/design/release-agent-contract.md b/dev/design/deployer-contract.md similarity index 93% rename from dev/design/release-agent-contract.md rename to dev/design/deployer-contract.md index ff54517b..261a34a6 100644 --- a/dev/design/release-agent-contract.md +++ b/dev/design/deployer-contract.md @@ -1,6 +1,6 @@ -# Release agent contract +# Deployer contract -This document describes the operator-facing contract for the DevClaw release agent. +This document describes the operator-facing contract for the DevClaw Deployer. Use it as the manual for how release promotion and acceptance are meant to work. @@ -54,7 +54,7 @@ A promotion request identifies at minimum: ### Prompt surface -Release work uses a dedicated `release.md` prompt surface. +The Deployer uses a dedicated `deployer.md` prompt surface. That prompt is where release-execution behavior belongs. It is not the source of truth for lanes, routing policy, allowed promotion paths, or proof requirements. Those remain structural workflow/config and runbook concerns. @@ -68,7 +68,7 @@ A promoted candidate is tied to an exact identity, such as: ### 4. Proof of release is mandatory -The release agent proves that it released the intended version. +The Deployer proves that it released the intended version. Minimum proof includes: - source candidate identity @@ -136,7 +136,7 @@ The contract defines: This contract lives primarily in project config and workflow semantics, not only in prompts. -Prompts can explain how a project uses the release agent, but they are not the sole source of truth for: +Prompts can explain how a project uses the Deployer, but they are not the sole source of truth for: - lane names - allowed promotion paths - acceptance authority @@ -145,7 +145,7 @@ Prompts can explain how a project uses the release agent, but they are not the s ## Operator checklist -A usable release-agent project setup defines at least: +A usable Deployer project setup defines at least: - release lanes or environments - allowed promotion paths between lanes - candidate identity requirements diff --git a/dev/runbooks/developing-devclaw-with-openclaw.md b/dev/runbooks/developing-devclaw-with-openclaw.md index b2278cca..df7b55f6 100644 --- a/dev/runbooks/developing-devclaw-with-openclaw.md +++ b/dev/runbooks/developing-devclaw-with-openclaw.md @@ -164,9 +164,9 @@ The point of the export is to publish local truth, not replace it. ## Promotion issue requirement -Generic release-agent contract and terminology for promotion, acceptance, proof of release, rollback, and operator initiation now live in: +Generic Deployer contract and terminology for promotion, acceptance, proof of release, rollback, and operator initiation now live in: -- `dev/design/release-agent-contract.md` +- `dev/design/deployer-contract.md` Use that design doc as the generic model. This runbook remains the DevClaw-specific mapping of that model onto local lanes such as `devclaw-local-dev`, `devclaw-local-current`, live self-hosted validation, and upstream handoff. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 04bdd9f2..250ec8ba 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -546,7 +546,7 @@ sequenceDiagram The source path is logged for production traceability: `Bootstrap hook: injected developer instructions for project "my-app" from /path/to/prompts/developer.md`. -Release work uses a dedicated `release.md` prompt surface. +The Deployer uses a dedicated `deployer.md` prompt surface. ## Data flow map @@ -759,7 +759,7 @@ See [CONFIGURATION.md](CONFIGURATION.md) for the full reference. | Worker state | `/devclaw/projects.json` | Per-project worker state | | Workflow config (workspace) | `/devclaw/workflow.yaml` | Workspace-level role/workflow overrides | | Workflow config (project) | `/devclaw/projects//workflow.yaml` | Project-specific overrides | -| Default role instructions | `/devclaw/prompts/.md` | Default `developer.md`, `reviewer.md`, `tester.md`, `release.md`, `architect.md` | +| Default role instructions | `/devclaw/prompts/.md` | Default `developer.md`, `reviewer.md`, `tester.md`, `deployer.md`, `architect.md` | | Project role instructions | `/devclaw/projects//prompts/.md` | Per-project role instruction overrides | | Audit log | `/devclaw/log/audit.log` | NDJSON event log | | Session transcripts | `~/.openclaw/agents//sessions/.jsonl` | Conversation history per session | diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index a8f70bc1..db9d7850 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -100,7 +100,7 @@ Release-agent configuration should also define: - required release evidence or proof receipts - retry and override behavior for repeated promotions -For the operator-facing contract, see [`../dev/design/release-agent-contract.md`](../dev/design/release-agent-contract.md). +For the operator-facing contract, see [`../dev/design/deployer-contract.md`](../dev/design/deployer-contract.md). ### Timeouts @@ -380,7 +380,7 @@ Each role in the `workers` record has a `WorkerState` object: │ │ ├── developer.md ← Default developer instructions │ │ ├── reviewer.md ← Default reviewer instructions │ │ ├── tester.md ← Default tester instructions -│ │ ├── release.md ← Default release instructions +│ │ ├── deployer.md ← Default Deployer instructions │ │ └── architect.md ← Default architect instructions │ ├── projects/ │ │ ├── my-webapp/ @@ -389,14 +389,14 @@ Each role in the `workers` record has a `WorkerState` object: │ │ │ ├── developer.md ← Project-specific developer instructions │ │ │ ├── reviewer.md ← Project-specific reviewer instructions │ │ │ ├── tester.md ← Project-specific tester instructions -│ │ │ ├── release.md ← Project-specific release instructions +│ │ │ ├── deployer.md ← Project-specific Deployer instructions │ │ │ └── architect.md ← Project-specific architect instructions │ │ └── another-project/ │ │ └── prompts/ │ │ ├── developer.md │ │ ├── reviewer.md │ │ ├── tester.md -│ │ └── release.md +│ │ └── deployer.md │ └── log/ │ └── audit.log ← NDJSON event log (auto-managed) ├── AGENTS.md ← Agent identity documentation @@ -409,7 +409,7 @@ Role instructions are injected into worker sessions via the `agent:bootstrap` ho Edit to customize: deployment steps, test commands, acceptance criteria, coding standards, promotion steps, and proof-of-release behavior. -Release work uses `release.md` as its dedicated prompt surface. +The Deployer uses `deployer.md` as its dedicated prompt surface. Release lanes, routing policy, and proof requirements belong in workflow/config and runbooks, not only in prompt text. diff --git a/docs/ONBOARDING.md b/docs/ONBOARDING.md index d6fbff91..5579ff29 100644 --- a/docs/ONBOARDING.md +++ b/docs/ONBOARDING.md @@ -155,7 +155,7 @@ Go to the Telegram/WhatsApp group for the project and tell the orchestrator agen The agent calls `project_register`, which atomically: - Validates the repo and auto-detects GitHub/GitLab from remote - Creates all state labels (idempotent) -- Scaffolds role instruction files (`devclaw/projects//prompts/developer.md`, `reviewer.md`, `tester.md`, `release.md`, `architect.md`) +- Scaffolds role instruction files (`devclaw/projects//prompts/developer.md`, `reviewer.md`, `tester.md`, `deployer.md`, `architect.md`) - Adds the project entry to `projects.json` - Logs the registration event diff --git a/docs/REQUIREMENTS.md b/docs/REQUIREMENTS.md index 564a96b5..656431ac 100644 --- a/docs/REQUIREMENTS.md +++ b/docs/REQUIREMENTS.md @@ -30,7 +30,7 @@ DevClaw orchestrates AI development agents across GitHub and GitLab projects. Th **Layer 2 — Workflow State Machine** controls _when_ work moves. Label-driven state transitions on the issue tracker. Heartbeat scans queues, dispatches workers, handles PR lifecycle. Configured per-workspace or per-project in `workflow.yaml`. -**Layer 3 — Role Prompts** controls _how_ work is done. System-level instructions injected into worker sessions via the bootstrap hook. Per-role (`developer.md`, `reviewer.md`, `tester.md`, `release.md`, `architect.md`) and per-project overrides. Release work uses `release.md` as its dedicated prompt surface. +**Layer 3 — Role Prompts** controls _how_ work is done. System-level instructions injected into worker sessions via the bootstrap hook. Per-role (`developer.md`, `reviewer.md`, `tester.md`, `deployer.md`, `architect.md`) and per-project overrides. The Deployer uses `deployer.md` as its dedicated prompt surface. **Layer 4 — Task Instructions** controls _what_ work is done. Built from issue description, comments, PR feedback, attachments. Constructed fresh on each dispatch. Includes mandatory completion instructions (`work_finish` call). diff --git a/docs/WORKFLOW.md b/docs/WORKFLOW.md index 51c5b5a3..044d8626 100644 --- a/docs/WORKFLOW.md +++ b/docs/WORKFLOW.md @@ -407,7 +407,7 @@ flowchart TD ### Release-agent contract -Delivery phases work together with the release-agent contract. +Delivery phases work together with the Deployer contract. Projects define: - lanes or environments @@ -416,13 +416,13 @@ Projects define: - acceptance criteria and authority - retry, repeat, and override behavior -For the operator-facing contract, see [`dev/design/release-agent-contract.md`](../dev/design/release-agent-contract.md). +For the operator-facing contract, see [`dev/design/deployer-contract.md`](../dev/design/deployer-contract.md). ### Prompt files for delivery phases -Release work uses a dedicated `release.md` prompt surface: -- Default: `devclaw/prompts/release.md` -- Per-project: `devclaw/projects//prompts/release.md` +The Deployer uses a dedicated `deployer.md` prompt surface: +- Default: `devclaw/prompts/deployer.md` +- Per-project: `devclaw/projects//prompts/deployer.md` Put release-execution instructions in that prompt surface. Put lane definitions, routing policy, and release proof requirements in workflow/config and project runbooks. diff --git a/docs/exploratory/CONTROL-LAYER.md b/docs/exploratory/CONTROL-LAYER.md index 96bda6fa..46b6f1bc 100644 --- a/docs/exploratory/CONTROL-LAYER.md +++ b/docs/exploratory/CONTROL-LAYER.md @@ -31,7 +31,7 @@ Instructions injected into the LLM context. The agent *should* follow them but * | `devclaw/prompts/developer.md` | Bootstrap hook → `WORKER_INSTRUCTIONS.md` | Work in worktrees, don't merge PR, no closing keywords in PR description | | `devclaw/prompts/reviewer.md` | Bootstrap hook → `WORKER_INSTRUCTIONS.md` | Review diff only, call task_comment first, then approve/reject | | `devclaw/prompts/tester.md` | Bootstrap hook → `WORKER_INSTRUCTIONS.md` | Run tests, always call task_comment with findings | -| `devclaw/prompts/release.md` | Bootstrap hook → `WORKER_INSTRUCTIONS.md` | Promotion steps, lane checks, release evidence, rollback handling | +| `devclaw/prompts/deployer.md` | Bootstrap hook → `WORKER_INSTRUCTIONS.md` | Promotion steps, lane checks, release evidence, rollback handling | | `AGENTS.md` | Workspace context file | Orchestrator must never write code, priority ordering, tool restrictions | | `SOUL.md` / `IDENTITY.md` | Workspace context file | Personality, communication style | | `buildTaskMessage()` | Appended to task message | Mandatory completion block: "you MUST call work_finish" with valid results | @@ -42,7 +42,7 @@ Role prompts are resolved per-project with fallback: 1. `devclaw/projects//prompts/.md` 2. `devclaw/prompts/.md` -Release work uses `release.md` as its dedicated prompt surface. +The Deployer uses `deployer.md` as its dedicated prompt surface. ### What can go wrong diff --git a/lib/tools/admin/workflow-guide.ts b/lib/tools/admin/workflow-guide.ts index 49d6f86d..8d6d7673 100644 --- a/lib/tools/admin/workflow-guide.ts +++ b/lib/tools/admin/workflow-guide.ts @@ -142,7 +142,7 @@ workflow: ## Release-agent contract - Workflow config covers delivery policies and the states they use. - Release-agent config also defines project lanes or environments, allowed source → target promotion paths, proof-of-release receipts, shared acceptance defaults, and repeat or override behavior. -- See \`dev/design/release-agent-contract.md\` in the repo for the operator-facing contract. +- See \`dev/design/deployer-contract.md\` in the repo for the operator-facing contract. ## Routing labels - Promotion uses \`promotion:human\`, \`promotion:agent\`, \`promotion:skip\` @@ -316,7 +316,7 @@ Each role can have a system prompt file: If a role has no prompt file, the worker gets a generic system prompt. When enabling a new role (like tester or release), create its prompt file. -Release work uses a dedicated \`release.md\` prompt surface. +The Deployer uses a dedicated \`deployer.md\` prompt surface. Keep release lanes, routing policy, and proof requirements in workflow/config and runbooks, not only in prompt text.`; } From e4861dddc6d34b374d24e216f0f4076ebdd8c903 Mon Sep 17 00:00:00 2001 From: fujiwaranosai850 Date: Sat, 9 May 2026 07:39:16 +0000 Subject: [PATCH 15/30] docs: include architect in prompt tree examples --- README.md | 6 ++++-- docs/CONFIGURATION.md | 3 ++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 77fd90d9..3678d5f2 100644 --- a/README.md +++ b/README.md @@ -382,13 +382,15 @@ devclaw/ │ ├── developer.md "Run npm test before committing. Deploy URL: staging.example.com" │ ├── reviewer.md "Code review rules and PR acceptance policy." │ ├── tester.md "Check OAuth flow. Verify mobile responsiveness." - │ └── deployer.md "Promotion steps, lane checks, proof-of-release requirements." + │ ├── deployer.md "Promotion steps, lane checks, proof-of-release requirements." + │ └── architect.md "Research alternatives and create implementation-ready tasks." └── my-api/ └── prompts/ ├── developer.md "Run cargo test. Follow REST conventions in CONTRIBUTING.md" ├── reviewer.md "Review API changes and PR quality." ├── tester.md "Verify all endpoints return correct status codes." - └── deployer.md "Promote approved builds between lanes and record evidence." + ├── deployer.md "Promote approved builds between lanes and record evidence." + └── architect.md "Research architecture tradeoffs before implementation." ``` Deployment steps, test commands, coding standards, acceptance criteria, promotion steps, and proof requirements are injected into worker sessions from these role prompt files. diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index db9d7850..311aa232 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -396,7 +396,8 @@ Each role in the `workers` record has a `WorkerState` object: │ │ ├── developer.md │ │ ├── reviewer.md │ │ ├── tester.md -│ │ └── deployer.md +│ │ ├── deployer.md +│ │ └── architect.md │ └── log/ │ └── audit.log ← NDJSON event log (auto-managed) ├── AGENTS.md ← Agent identity documentation From c74e96be96229ef037c5894023a46ed193bcf1b2 Mon Sep 17 00:00:00 2001 From: fujiwaranosai850 Date: Sat, 9 May 2026 08:25:57 +0000 Subject: [PATCH 16/30] prompts: add default deployer prompt --- defaults/devclaw/prompts/deployer.md | 86 ++++++++++++++++++++++++++++ lib/setup/templates.ts | 2 + 2 files changed, 88 insertions(+) create mode 100644 defaults/devclaw/prompts/deployer.md diff --git a/defaults/devclaw/prompts/deployer.md b/defaults/devclaw/prompts/deployer.md new file mode 100644 index 00000000..127e84e6 --- /dev/null +++ b/defaults/devclaw/prompts/deployer.md @@ -0,0 +1,86 @@ +# DEPLOYER Worker Instructions + +You are the Deployer. Your job is to move an exact approved candidate from one release lane to another, verify the result, and record proof of release. + +## Context You Receive + +When you start work, you're given: + +- **Issue:** number, title, body, URL, labels, state +- **Comments:** full discussion thread on the issue +- **Project:** repo path, base branch, project name, projectSlug +- **Release context:** source lane, target lane, candidate identity, required evidence, and any project-specific runbook steps + +Read the issue body and comments carefully. Release work is evidence-sensitive. Do not guess at lane meaning, candidate identity, or acceptance rules. + +## Your Job + +1. **Understand the requested release step** + - Identify whether you are promoting, validating, accepting, or rolling back a candidate + - Confirm the source lane and target lane + - Confirm the exact candidate identity + +2. **Verify preconditions** + - Make sure the requested lane transition is allowed + - Make sure the candidate is the intended one + - Make sure any required approvals, checks, or prerequisites are satisfied before proceeding + +3. **Execute the release step** + - Follow the project runbook exactly + - Perform the required promotion, validation, acceptance, or rollback action + - Do not improvise a different release path because it seems close enough + +4. **Verify the result** + - Confirm the destination lane now contains the intended candidate + - Confirm the destination identity matches the requested promotion + - Confirm any required checks or validation evidence are collected + +5. **Record proof** + - Call `task_comment` with a release receipt that includes: + - source lane + - target lane + - candidate identity + - resulting destination identity or state + - verification evidence + - any relevant runbook notes + +6. **Escalate cleanly if blocked** + - If required evidence is missing, lane rules are unclear, or the release cannot be proven, stop and report the exact blocker + - Do not mark a release complete when proof is incomplete + +## Conventions + +- Treat workflow/config and project runbooks as the source of truth for lane definitions, allowed paths, and release policy +- Treat prompt instructions as execution guidance, not as a replacement for structural release rules +- Never guess at candidate identity +- Never claim success without proof +- Be explicit about what changed, where it changed, and how you verified it +- If a candidate must be demoted or rolled back, record that explicitly +- **Do NOT use closing keywords in PR/MR descriptions** (no "Closes #X", "Fixes #X", "Resolves #X"). Use "As described in issue #X" or "Addresses issue #X" instead + +## Filing Follow-Up Issues + +If you discover unrelated release-process gaps, environment drift, or missing tooling, call `task_create`: + +`task_create({ projectSlug: "", title: "Release: ...", description: "..." })` + +## Completing Your Task + +When you are done, **call `work_finish` yourself** — do not just announce in text. + +Use the completion result required by the active delivery state and workflow step you are executing. + +Your summary should include: +- the lane transition attempted +- the candidate identity +- the resulting destination state +- whether proof was successfully recorded + +If blocked, say exactly what proof, approval, environment access, or lane rule is missing. + +The `projectSlug` is included in your task message. + +## Tools You Should NOT Use + +These are orchestrator-only tools. Do not call them: +- `task_start`, `tasks_status`, `health`, `project_register` diff --git a/lib/setup/templates.ts b/lib/setup/templates.ts index 82e46bdb..abd59203 100644 --- a/lib/setup/templates.ts +++ b/lib/setup/templates.ts @@ -49,6 +49,7 @@ const DEFAULT_DEV_INSTRUCTIONS = loadDefault("devclaw/prompts/developer.md"); const DEFAULT_QA_INSTRUCTIONS = loadDefault("devclaw/prompts/tester.md"); const DEFAULT_ARCHITECT_INSTRUCTIONS = loadDefault("devclaw/prompts/architect.md"); const DEFAULT_REVIEWER_INSTRUCTIONS = loadDefault("devclaw/prompts/reviewer.md"); +const DEFAULT_DEPLOYER_INSTRUCTIONS = loadDefault("devclaw/prompts/deployer.md"); export const DEFAULT_ORCHESTRATOR_INSTRUCTIONS = loadDefault("devclaw/prompts/orchestrator.md"); /** Default role instructions indexed by role ID. Used by project scaffolding. */ @@ -57,6 +58,7 @@ export const DEFAULT_ROLE_INSTRUCTIONS: Record = { tester: DEFAULT_QA_INSTRUCTIONS, architect: DEFAULT_ARCHITECT_INSTRUCTIONS, reviewer: DEFAULT_REVIEWER_INSTRUCTIONS, + deployer: DEFAULT_DEPLOYER_INSTRUCTIONS, }; // --------------------------------------------------------------------------- From a5350c77cbf401aa771cb430fc8c198f35c3ec1a Mon Sep 17 00:00:00 2001 From: fujiwaranosai850 Date: Sat, 9 May 2026 09:26:07 +0000 Subject: [PATCH 17/30] workflow: add deployer delivery role (cherry picked from commit b322e8f261664a90cb202d49e9a43c245fef30c6) --- defaults/devclaw/workflow.yaml | 61 +++++++++++++++++++++++++- lib/config/schema.test.ts | 14 +++--- lib/config/schema.ts | 4 +- lib/dispatch/bootstrap-hook.test.ts | 20 +++++++++ lib/roles/registry.test.ts | 9 ++++ lib/roles/registry.ts | 19 ++++++++ lib/services/delivery-phases.test.ts | 23 +++++----- lib/services/heartbeat/index.ts | 1 + lib/services/pipeline-delivery.test.ts | 6 +-- lib/services/pipeline.ts | 2 + lib/services/tick.ts | 45 ++++++++++++------- lib/testing/harness.ts | 1 + lib/tools/admin/workflow-guide.ts | 6 ++- lib/tools/worker/work-finish.ts | 2 +- lib/workflow/defaults.ts | 15 +++---- lib/workflow/labels.ts | 1 + 16 files changed, 176 insertions(+), 53 deletions(-) diff --git a/defaults/devclaw/workflow.yaml b/defaults/devclaw/workflow.yaml index 67090a95..5eee3573 100644 --- a/defaults/devclaw/workflow.yaml +++ b/defaults/devclaw/workflow.yaml @@ -26,6 +26,10 @@ roles: models: junior: anthropic/claude-haiku-4-5 senior: anthropic/claude-sonnet-4-5 + deployer: + models: + junior: anthropic/claude-haiku-4-5 + senior: anthropic/claude-sonnet-4-5 workflow: initial: planning @@ -154,7 +158,47 @@ workflow: label: Testing color: "#9b59b6" on: - PASS: + PASS: toPromote + FAIL: + target: toImprove + actions: + - reopenIssue + REFINE: refining + BLOCKED: refining + toPromote: + type: queue + role: deployer + label: To Promote + color: "#1d76db" + priority: 2 + on: + PICKUP: promoting + SKIP: toAccept + PROMOTED: toAccept + FAIL: toImprove + DEMOTED: toImprove + BLOCKED: refining + promoting: + type: active + role: deployer + label: Promoting + color: "#6ea8fe" + on: + COMPLETE: toAccept + BLOCKED: refining + toAccept: + type: queue + role: deployer + label: To Accept + color: "#20c997" + priority: 2 + on: + PICKUP: accepting + SKIP: + target: done + actions: + - closeIssue + ACCEPTED: target: done actions: - closeIssue @@ -162,8 +206,23 @@ workflow: target: toImprove actions: - reopenIssue + DEMOTED: + target: toImprove + actions: + - reopenIssue REFINE: refining BLOCKED: refining + accepting: + type: active + role: deployer + label: Accepting + color: "#8ce0c4" + on: + COMPLETE: + target: done + actions: + - closeIssue + BLOCKED: refining done: type: terminal label: Done diff --git a/lib/config/schema.test.ts b/lib/config/schema.test.ts index 7ad6b001..1ec01450 100644 --- a/lib/config/schema.test.ts +++ b/lib/config/schema.test.ts @@ -4,25 +4,25 @@ 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", () => { + it("rejects promotion states that are not deployer-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")); + assert.ok(errors.includes("workflow.delivery.promotion.queueState must reference a deployer-owned state")); + assert.ok(errors.includes("workflow.delivery.promotion.activeState must reference a deployer-owned state")); }); - it("rejects acceptance states that are not tester-owned", () => { + it("rejects acceptance states that are not deployer-owned", () => { const workflow = structuredClone(DEFAULT_WORKFLOW); workflow.delivery!.acceptance!.queueState = "toReview"; - workflow.delivery!.acceptance!.activeState = "promoting"; + workflow.delivery!.acceptance!.activeState = "testing"; 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")); + assert.ok(errors.includes("workflow.delivery.acceptance.queueState must reference a deployer-owned state")); + assert.ok(errors.includes("workflow.delivery.acceptance.activeState must reference a deployer-owned state")); }); }); diff --git a/lib/config/schema.ts b/lib/config/schema.ts index 4c59f5d4..e5af1754 100644 --- a/lib/config/schema.ts +++ b/lib/config/schema.ts @@ -122,11 +122,11 @@ export function validateWorkflowIntegrity( } const state = workflow.states[value]; const expectedType = stateKind === "queueState" ? StateType.QUEUE : StateType.ACTIVE; - const expectedRole = phase === "promotion" ? "reviewer" : "tester"; + const expectedRole = phase === "promotion" || phase === "acceptance" ? "deployer" : undefined; if (state?.type !== expectedType) { errors.push(`workflow.delivery.${phase}.${stateKind} must reference a ${expectedType} state`); } - if (state?.role !== expectedRole) { + if (expectedRole && state?.role !== expectedRole) { errors.push(`workflow.delivery.${phase}.${stateKind} must reference a ${expectedRole}-owned state`); } }; diff --git a/lib/dispatch/bootstrap-hook.test.ts b/lib/dispatch/bootstrap-hook.test.ts index 9a806989..4e9c7e60 100644 --- a/lib/dispatch/bootstrap-hook.test.ts +++ b/lib/dispatch/bootstrap-hook.test.ts @@ -27,6 +27,11 @@ describe("parseDevClawSessionKey", () => { assert.deepStrictEqual(result, { projectName: "webapp", role: "tester" }); }); + it("should parse a deployer session key", () => { + const result = parseDevClawSessionKey("agent:devclaw:subagent:webapp-deployer-junior"); + assert.deepStrictEqual(result, { projectName: "webapp", role: "deployer" }); + }); + it("should handle project names with hyphens", () => { const result = parseDevClawSessionKey("agent:devclaw:subagent:my-cool-project-developer-junior"); assert.deepStrictEqual(result, { projectName: "my-cool-project", role: "developer" }); @@ -147,6 +152,21 @@ describe("loadRoleInstructions", () => { await fs.rm(tmpDir, { recursive: true }); }); + it("should load deployer instructions from both workspace and package defaults", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-test-")); + const promptsDir = path.join(tmpDir, "devclaw", "prompts"); + await fs.mkdir(promptsDir, { recursive: true }); + await fs.writeFile(path.join(promptsDir, "deployer.md"), "# Deployer Default\nPromote carefully."); + + const workspaceResult = await loadRoleInstructions(tmpDir, "missing", "deployer"); + assert.strictEqual(workspaceResult, "# Deployer Default\nPromote carefully."); + + await fs.rm(tmpDir, { recursive: true }); + + const packageResult = await loadRoleInstructions(process.cwd(), "missing", "deployer"); + assert.strictEqual(packageResult, DEFAULT_ROLE_INSTRUCTIONS.deployer); + }); + it("should return empty string for unknown roles with no defaults", async () => { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-test-")); diff --git a/lib/roles/registry.test.ts b/lib/roles/registry.test.ts index 8835028f..51ba1c4d 100644 --- a/lib/roles/registry.test.ts +++ b/lib/roles/registry.test.ts @@ -33,6 +33,7 @@ describe("role registry", () => { assert.ok(ids.includes("tester")); assert.ok(ids.includes("architect")); assert.ok(ids.includes("reviewer")); + assert.ok(ids.includes("deployer")); }); it("should validate role IDs", () => { @@ -40,6 +41,7 @@ describe("role registry", () => { assert.strictEqual(isValidRole("tester"), true); assert.strictEqual(isValidRole("architect"), true); assert.strictEqual(isValidRole("reviewer"), true); + assert.strictEqual(isValidRole("deployer"), true); assert.strictEqual(isValidRole("nonexistent"), false); }); @@ -61,6 +63,7 @@ describe("levels", () => { assert.deepStrictEqual([...getLevelsForRole("tester")], ["junior", "medior", "senior"]); assert.deepStrictEqual([...getLevelsForRole("architect")], ["junior", "senior"]); assert.deepStrictEqual([...getLevelsForRole("reviewer")], ["junior", "senior"]); + assert.deepStrictEqual([...getLevelsForRole("deployer")], ["junior", "senior"]); }); it("should return empty for unknown role", () => { @@ -133,6 +136,7 @@ describe("models", () => { assert.strictEqual(getDefaultModel("developer", "medior"), "anthropic/claude-sonnet-4-5"); assert.strictEqual(getDefaultModel("tester", "medior"), "anthropic/claude-sonnet-4-5"); assert.strictEqual(getDefaultModel("architect", "senior"), "anthropic/claude-opus-4-6"); + assert.strictEqual(getDefaultModel("deployer", "senior"), "anthropic/claude-sonnet-4-5"); }); it("should return all default models", () => { @@ -192,6 +196,7 @@ describe("completion results", () => { assert.deepStrictEqual([...getCompletionResults("tester")], ["pass", "fail", "refine", "blocked"]); assert.deepStrictEqual([...getCompletionResults("architect")], ["done", "blocked"]); assert.deepStrictEqual([...getCompletionResults("reviewer")], ["approve", "reject", "blocked"]); + assert.deepStrictEqual([...getCompletionResults("deployer")], ["done", "blocked"]); }); it("should validate results", () => { @@ -203,6 +208,8 @@ describe("completion results", () => { assert.strictEqual(isValidResult("reviewer", "reject"), true); assert.strictEqual(isValidResult("reviewer", "escalate"), false); assert.strictEqual(isValidResult("reviewer", "done"), false); + assert.strictEqual(isValidResult("deployer", "done"), true); + assert.strictEqual(isValidResult("deployer", "approve"), false); }); }); @@ -213,6 +220,7 @@ describe("session key pattern", () => { assert.ok(pattern.includes("tester")); assert.ok(pattern.includes("architect")); assert.ok(pattern.includes("reviewer")); + assert.ok(pattern.includes("deployer")); }); it("should work as regex", () => { @@ -222,6 +230,7 @@ describe("session key pattern", () => { assert.ok(regex.test("tester")); assert.ok(regex.test("architect")); assert.ok(regex.test("reviewer")); + assert.ok(regex.test("deployer")); assert.ok(!regex.test("nonexistent")); }); }); diff --git a/lib/roles/registry.ts b/lib/roles/registry.ts index f7deb433..bd61e196 100644 --- a/lib/roles/registry.ts +++ b/lib/roles/registry.ts @@ -93,4 +93,23 @@ export const ROLE_REGISTRY: Record = { sessionKeyPattern: "reviewer", notifications: { onStart: true, onComplete: true }, }, + + deployer: { + id: "deployer", + displayName: "DEPLOYER", + levels: ["junior", "senior"], + defaultLevel: "junior", + models: { + junior: "anthropic/claude-haiku-4-5", + senior: "anthropic/claude-sonnet-4-5", + }, + emoji: { + junior: "🚚", + senior: "🚀", + }, + fallbackEmoji: "🚚", + completionResults: ["done", "blocked"], + sessionKeyPattern: "deployer", + notifications: { onStart: true, onComplete: true }, + }, }; diff --git a/lib/services/delivery-phases.test.ts b/lib/services/delivery-phases.test.ts index a353d128..b677c457 100644 --- a/lib/services/delivery-phases.test.ts +++ b/lib/services/delivery-phases.test.ts @@ -12,9 +12,9 @@ describe("delivery phase routing", () => { 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"); + it("derives deployer completion rules from delivery active states", () => { + const promoteRule = getCompletionRule(DEFAULT_WORKFLOW, "deployer", "done", "Promoting"); + const acceptRule = getCompletionRule(DEFAULT_WORKFLOW, "deployer", "done", "Accepting"); assert.deepStrictEqual(promoteRule, { from: "Promoting", @@ -31,31 +31,32 @@ describe("delivery phase routing", () => { 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 }, + deployer: { 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({ + const promotionTick = await projectTick({ workspaceDir: h.workspaceDir, projectSlug: h.project.slug, provider: h.provider, - targetRole: "reviewer", + targetRole: "deployer", runCommand: h.runCommand, }); - const testerTick = await projectTick({ + + assert.strictEqual(promotionTick.pickups.length, 1); + + const acceptanceTick = await projectTick({ workspaceDir: h.workspaceDir, projectSlug: h.project.slug, provider: h.provider, - targetRole: "tester", + targetRole: "deployer", runCommand: h.runCommand, }); - assert.strictEqual(reviewerTick.pickups.length, 1); - assert.strictEqual(testerTick.pickups.length, 1); + assert.strictEqual(acceptanceTick.pickups.length, 1); const transitions = h.provider.callsTo("transitionLabel"); assert.deepStrictEqual(transitions.map((call) => call.args), [ diff --git a/lib/services/heartbeat/index.ts b/lib/services/heartbeat/index.ts index 44d010c7..8805d423 100644 --- a/lib/services/heartbeat/index.ts +++ b/lib/services/heartbeat/index.ts @@ -130,6 +130,7 @@ async function processAllAgents( totalReviewTransitions: 0, totalReviewSkipTransitions: 0, totalTestSkipTransitions: 0, + totalDeliveryTransitions: 0, }; // Ensure defaults are fresh on every startup (prompts, workflow, etc.) diff --git a/lib/services/pipeline-delivery.test.ts b/lib/services/pipeline-delivery.test.ts index 6e6f04db..192b56e2 100644 --- a/lib/services/pipeline-delivery.test.ts +++ b/lib/services/pipeline-delivery.test.ts @@ -14,7 +14,7 @@ describe("executeCompletion delivery provenance", () => { it("records an active candidate when promotion completes into acceptance", async () => { h = await createTestHarness({ workers: { - reviewer: { active: true, issueId: "26", level: "junior" }, + deployer: { active: true, issueId: "26", level: "junior" }, }, }); h.provider.seedIssue({ iid: 26, title: "Promote PR", labels: ["Promoting"] }); @@ -23,8 +23,8 @@ describe("executeCompletion delivery provenance", () => { workspaceDir: h.workspaceDir, projectSlug: h.project.slug, channels: h.project.channels, - role: "reviewer", - result: "approve", + role: "deployer", + result: "done", issueId: 26, summary: "Promoted candidate", provider: h.provider, diff --git a/lib/services/pipeline.ts b/lib/services/pipeline.ts index 074df178..83a52248 100644 --- a/lib/services/pipeline.ts +++ b/lib/services/pipeline.ts @@ -52,6 +52,8 @@ function getRefiningCommentPrefix(role: string): string { return "👁️ **REVIEWER**"; case "architect": return "🏗️ **ARCHITECT**"; + case "deployer": + return "🚚 **DEPLOYER**"; default: return "🎛️ **ORCHESTRATOR**"; } diff --git a/lib/services/tick.ts b/lib/services/tick.ts index e459732e..adf7656c 100644 --- a/lib/services/tick.ts +++ b/lib/services/tick.ts @@ -20,6 +20,7 @@ import { TestPolicy, getActiveLabel, getActiveLabelForQueueLabel, + getDeliveryQueueLabel, type WorkflowConfig, type Role, } from "../workflow/index.js"; @@ -127,8 +128,13 @@ export async function projectTick(opts: { const { issue, label: currentLabel } = next; const targetLabel = getActiveLabelForQueueLabel(workflow, role, currentLabel); + const promotionQueueLabel = getDeliveryQueueLabel(workflow, "promotion"); + const acceptanceQueueLabel = getDeliveryQueueLabel(workflow, "acceptance"); + const isPromotionQueue = currentLabel === promotionQueueLabel; + const isAcceptanceQueue = currentLabel === acceptanceQueueLabel; + // Fallback policy gates for legacy issues that predate routing labels. - if (role === "reviewer" && currentLabel !== workflow.states[workflow.delivery?.promotion?.queueState ?? ""]?.label) { + if (role === "reviewer" && !isPromotionQueue) { const reviewRouting = detectStepRouting(issue.labels, "review"); const policy = workflow.reviewPolicy ?? ReviewPolicy.HUMAN; if (!reviewRouting && (policy === ReviewPolicy.HUMAN || policy === ReviewPolicy.SKIP)) { @@ -137,7 +143,7 @@ export async function projectTick(opts: { } } - if (role === "tester" && currentLabel !== workflow.states[workflow.delivery?.acceptance?.queueState ?? ""]?.label) { + if (role === "tester" && !isAcceptanceQueue) { const testRouting = detectStepRouting(issue.labels, "test"); const policy = workflow.testPolicy ?? TestPolicy.SKIP; if (!testRouting && policy === TestPolicy.SKIP) { @@ -146,26 +152,31 @@ export async function projectTick(opts: { } } - // Step routing: check for human/skip routing labels on queue phases - if (role === "reviewer") { + // Step routing: check for human/skip routing labels on queue phases. + if (isPromotionQueue) { + const promotionRouting = detectStepRouting(issue.labels, "promotion"); + if (promotionRouting === "human" || promotionRouting === "skip") { + skipped.push({ role, reason: `promotion:${promotionRouting} label` }); + continue; + } + } else if (role === "reviewer") { const reviewRouting = detectStepRouting(issue.labels, "review"); - const promotionRouting = currentLabel === workflow.states[workflow.delivery?.promotion?.queueState ?? ""]?.label - ? detectStepRouting(issue.labels, "promotion") - : null; - const routing = promotionRouting ?? reviewRouting; - if (routing === "human" || routing === "skip") { - skipped.push({ role, reason: `${promotionRouting ? "promotion" : "review"}:${routing} label` }); + if (reviewRouting === "human" || reviewRouting === "skip") { + skipped.push({ role, reason: `review:${reviewRouting} label` }); continue; } } - if (role === "tester") { + + if (isAcceptanceQueue) { + const acceptanceRouting = detectStepRouting(issue.labels, "acceptance"); + if (acceptanceRouting === "human" || acceptanceRouting === "skip") { + skipped.push({ role, reason: `acceptance:${acceptanceRouting} label` }); + continue; + } + } else if (role === "tester") { const testRouting = detectStepRouting(issue.labels, "test"); - const acceptanceRouting = currentLabel === workflow.states[workflow.delivery?.acceptance?.queueState ?? ""]?.label - ? detectStepRouting(issue.labels, "acceptance") - : null; - const routing = acceptanceRouting ?? testRouting; - if (routing === "human" || routing === "skip") { - skipped.push({ role, reason: `${acceptanceRouting ? "acceptance" : "test"}:${routing} label` }); + if (testRouting === "human" || testRouting === "skip") { + skipped.push({ role, reason: `test:${testRouting} label` }); continue; } } diff --git a/lib/testing/harness.ts b/lib/testing/harness.ts index aa083d02..975ada33 100644 --- a/lib/testing/harness.ts +++ b/lib/testing/harness.ts @@ -158,6 +158,7 @@ export async function createTestHarness(opts?: HarnessOptions): Promise/prompts/.md\` - Project override: \`/projects//prompts/.md\` -If a role has no prompt file, the worker gets a generic system prompt. When enabling a new role (like tester or release), create its prompt file. +If a role has no prompt file, the worker gets a generic system prompt. When enabling a new role (like tester or deployer), create its prompt file. The Deployer uses a dedicated \`deployer.md\` prompt surface. diff --git a/lib/tools/worker/work-finish.ts b/lib/tools/worker/work-finish.ts index b3b23cb0..d4abc3a6 100644 --- a/lib/tools/worker/work-finish.ts +++ b/lib/tools/worker/work-finish.ts @@ -179,7 +179,7 @@ export function createWorkFinishTool(ctx: PluginContext) { return (toolCtx: ToolContext) => ({ name: "work_finish", label: "Work Finish", - description: `Complete a task: Developer done (PR created, goes to review) or blocked. Tester pass/fail/refine/blocked. Reviewer approve/reject/blocked. Architect done/blocked. Handles label transition, state update, issue close/reopen, notifications, and audit logging.`, + description: `Complete a task: Developer done/blocked, Tester pass/fail/refine/blocked, Reviewer approve/reject/blocked, Architect done/blocked, or Deployer done/blocked. Handles label transition, state update, issue close/reopen, notifications, and audit logging.`, parameters: { type: "object", required: ["channelId", "role", "result"], diff --git a/lib/workflow/defaults.ts b/lib/workflow/defaults.ts index 9690615f..90e937a7 100644 --- a/lib/workflow/defaults.ts +++ b/lib/workflow/defaults.ts @@ -101,7 +101,7 @@ export const DEFAULT_WORKFLOW: WorkflowConfig = { }, toPromote: { type: StateType.QUEUE, - role: "reviewer", + role: "deployer", label: "To Promote", color: "#1d76db", priority: 2, @@ -116,18 +116,17 @@ export const DEFAULT_WORKFLOW: WorkflowConfig = { }, promoting: { type: StateType.ACTIVE, - role: "reviewer", + role: "deployer", label: "Promoting", color: "#6ea8fe", on: { - [WorkflowEvent.APPROVE]: "toAccept", - [WorkflowEvent.REJECT]: "toImprove", + [WorkflowEvent.COMPLETE]: "toAccept", [WorkflowEvent.BLOCKED]: "refining", }, }, toAccept: { type: StateType.QUEUE, - role: "tester", + role: "deployer", label: "To Accept", color: "#20c997", priority: 2, @@ -143,13 +142,11 @@ export const DEFAULT_WORKFLOW: WorkflowConfig = { }, accepting: { type: StateType.ACTIVE, - role: "tester", + role: "deployer", label: "Accepting", color: "#8ce0c4", on: { - [WorkflowEvent.PASS]: { target: "done", actions: [Action.CLOSE_ISSUE] }, - [WorkflowEvent.FAIL]: { target: "toImprove", actions: [Action.REOPEN_ISSUE] }, - [WorkflowEvent.REFINE]: "refining", + [WorkflowEvent.COMPLETE]: { target: "done", actions: [Action.CLOSE_ISSUE] }, [WorkflowEvent.BLOCKED]: "refining", }, }, diff --git a/lib/workflow/labels.ts b/lib/workflow/labels.ts index 8cfac5a4..19d0fa96 100644 --- a/lib/workflow/labels.ts +++ b/lib/workflow/labels.ts @@ -135,6 +135,7 @@ const ROLE_LABEL_COLORS: Record = { tester: "#5319e7", architect: "#0075ca", reviewer: "#d93f0b", + deployer: "#1d76db", }; /** From 7c544373c1d38861ae52d302143e477b2f91825b Mon Sep 17 00:00:00 2001 From: fujiwaranosai850 Date: Mon, 11 May 2026 02:13:53 +0000 Subject: [PATCH 18/30] projects: backfill newly added worker roles --- lib/projects/migrations.ts | 9 +++++++ lib/projects/mutations.ts | 5 +++- lib/projects/projects.test.ts | 39 +++++++++++++++++++++++++++++++ lib/tools/admin/project-status.ts | 9 ++++++- 4 files changed, 60 insertions(+), 2 deletions(-) diff --git a/lib/projects/migrations.ts b/lib/projects/migrations.ts index ced82bba..5c717ff6 100644 --- a/lib/projects/migrations.ts +++ b/lib/projects/migrations.ts @@ -13,6 +13,7 @@ */ import type { RoleWorkerState, SlotState, Project, Channel } from "./types.js"; +import { ROLE_REGISTRY } from "../roles/registry.js"; // --------------------------------------------------------------------------- // Role aliases — old role IDs → canonical IDs @@ -229,6 +230,14 @@ export function migrateProject(project: Project): boolean { } } else { project.workers = {}; + changed = true; + } + + for (const role of Object.keys(ROLE_REGISTRY)) { + if (!project.workers[role]) { + project.workers[role] = { levels: {} }; + changed = true; + } } // Telegram channels: legacy topicId → messageThreadId; drop topicId (canonical field only) diff --git a/lib/projects/mutations.ts b/lib/projects/mutations.ts index 9b79d91b..11d5d157 100644 --- a/lib/projects/mutations.ts +++ b/lib/projects/mutations.ts @@ -13,7 +13,10 @@ export function getRoleWorker( project: Project, role: string, ): RoleWorkerState { - return project.workers[role] ?? { levels: {} }; + if (!project.workers[role]) { + project.workers[role] = { levels: {} }; + } + return project.workers[role]!; } /** diff --git a/lib/projects/projects.test.ts b/lib/projects/projects.test.ts index fddc2999..ee0ed2ca 100644 --- a/lib/projects/projects.test.ts +++ b/lib/projects/projects.test.ts @@ -342,6 +342,45 @@ describe("readProjects migration", () => { await fs.rm(tmpDir, { recursive: true }); }); + + it("should backfill newly registered roles into existing project worker state", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-proj-")); + const dataDir = path.join(tmpDir, "devclaw"); + await fs.mkdir(dataDir, { recursive: true }); + + const raw = { + projects: { + p1: { + slug: "p1", + name: "P1", + repo: "~/p1", + groupName: "P1", + deployUrl: "", + baseBranch: "main", + deployBranch: "main", + channels: [{ channelId: "-100", channel: "telegram", name: "primary", events: ["*"] }], + workers: { + developer: emptyRoleWorkerState({ junior: 1 }), + tester: emptyRoleWorkerState({ junior: 1 }), + architect: emptyRoleWorkerState({ senior: 1 }), + reviewer: emptyRoleWorkerState({ junior: 1 }), + }, + }, + }, + }; + await fs.writeFile(path.join(dataDir, "projects.json"), JSON.stringify(raw), "utf-8"); + + const data = await readProjects(tmpDir); + assert.ok(data.projects.p1.workers.deployer, "should backfill deployer worker state"); + assert.deepStrictEqual(data.projects.p1.workers.deployer.levels, {}); + + const disk = JSON.parse(await fs.readFile(path.join(dataDir, "projects.json"), "utf-8")) as { + projects: { p1: { workers: Record }> } }; + }; + assert.ok(disk.projects.p1.workers.deployer, "should persist backfilled deployer worker state to disk"); + + await fs.rm(tmpDir, { recursive: true }); + }); }); describe("per-level slot helpers", () => { diff --git a/lib/tools/admin/project-status.ts b/lib/tools/admin/project-status.ts index 5327b01d..637a375b 100644 --- a/lib/tools/admin/project-status.ts +++ b/lib/tools/admin/project-status.ts @@ -12,6 +12,7 @@ import { requireWorkspaceDir, resolveChannelId, resolveProject } from "../helper import { ExecutionMode, StateType } from "../../workflow/index.js"; import { loadConfig } from "../../config/index.js"; import { loadInstanceName } from "../../instance.js"; +import { getRoleWorker, reconcileSlots } from "../../projects/index.js"; export function createProjectStatusTool(ctx: PluginContext) { return (toolCtx: ToolContext) => ({ @@ -61,8 +62,14 @@ export function createProjectStatusTool(ctx: PluginContext) { activeSlots: number; levels: Record>; }> = {}; - for (const [role, rw] of Object.entries(project.workers)) { + const roles = new Set([ + ...Object.keys(projectConfig.roles), + ...Object.keys(project.workers), + ]); + for (const role of roles) { const levelMaxWorkers = projectConfig.roles[role]?.levelMaxWorkers ?? {}; + const rw = getRoleWorker(project, role); + reconcileSlots(rw, levelMaxWorkers); let activeSlots = 0; const levels: Record> = {}; for (const [level, slots] of Object.entries(rw.levels)) { From 376c5b738bff60808668638f41ad5156cafa13b8 Mon Sep 17 00:00:00 2001 From: fujiwaranosai850 Date: Tue, 26 May 2026 19:05:48 +0000 Subject: [PATCH 19/30] fix: bootstrap worker issue worktrees (#238) --- defaults/devclaw/prompts/developer.md | 39 +++++--- defaults/devclaw/prompts/tester.md | 15 +++- .../worker-worktree-bootstrap-contract.md | 90 +++++++++++++++++++ dev/scripts/bootstrap-issue-worktree.sh | 48 ++++++++++ lib/dispatch/message-builder.ts | 12 +++ lib/dispatch/worktree-bootstrap.test.ts | 34 +++++++ lib/dispatch/worktree-bootstrap.ts | 73 +++++++++++++++ 7 files changed, 295 insertions(+), 16 deletions(-) create mode 100644 dev/design/worker-worktree-bootstrap-contract.md create mode 100755 dev/scripts/bootstrap-issue-worktree.sh create mode 100644 lib/dispatch/worktree-bootstrap.test.ts create mode 100644 lib/dispatch/worktree-bootstrap.ts diff --git a/defaults/devclaw/prompts/developer.md b/defaults/devclaw/prompts/developer.md index 60b704d0..6492df99 100644 --- a/defaults/devclaw/prompts/developer.md +++ b/defaults/devclaw/prompts/developer.md @@ -12,28 +12,27 @@ Read the comments carefully — they often contain clarifications, decisions, or ## Workflow -### 1. Create a worktree +### 1. Bootstrap the worker worktree -**NEVER work in the main checkout.** Create a dedicated git worktree as a sibling to the repo: +**NEVER work in the main checkout.** Use the bootstrap contract from the task message. -```bash -# Example: repo is at ~/git/myproject -# Worktree goes to ~/git/myproject.worktrees/feature/123-add-auth -REPO_ROOT="$(git rev-parse --show-toplevel)" -BRANCH="feature/-" -WORKTREE="${REPO_ROOT}.worktrees/${BRANCH}" -git worktree add "$WORKTREE" -b "$BRANCH" -cd "$WORKTREE" -``` +The task message gives you: +- the required branch name +- the required worktree path +- the exact `dev/scripts/bootstrap-issue-worktree.sh ...` command to run -The `.worktrees/` directory sits NEXT TO the repo folder (not inside it). This keeps the main checkout clean for the orchestrator and other workers. If a worktree already exists from a previous task on the same branch, verify it's clean before reusing it. +That bootstrap command is the source of truth. Run it before validation. It creates or reuses the dedicated issue worktree and provisions per-worktree dependencies with `npm install` when `node_modules` is missing or stale. + +The `.worktrees/` directory sits NEXT TO the repo folder (not inside it). This keeps the main checkout clean for the orchestrator and other workers. ### 2. Implement the changes - Read the issue description and comments thoroughly - Make the changes described in the issue - Follow existing code patterns and conventions in the project -- Run tests/linting if the project has them configured +- Run validation from the bootstrapped worktree +- Required handoff target: `npm run build` must pass +- Best-effort target: run `npm run check` ### 3. Commit and push @@ -70,7 +69,19 @@ When your task message includes a **PR Feedback** section, it means a reviewer r 5. Commit and push to the **same branch** — the existing PR updates automatically 6. Call `work_finish` as usual -### 5. Call work_finish +### 5. Classify failures correctly + +Do not collapse every problem into a product blocker. + +- **environment/bootstrap failure**: worktree creation failed, dependencies could not be installed, required tooling is missing, or local validation cannot start +- **ambient validation noise**: repo-wide failures already exist and are not caused by your issue changes +- **issue-local implementation failure**: your issue changes are still incorrect or incomplete + +If `npm run check` is noisy for ambient reasons but your issue changes are correct and `npm run build` passes, summarize the ambient noise clearly and still complete the implementation normally. + +If you must block, include the category name in the `work_finish` summary. + +### 6. Call work_finish ``` work_finish({ role: "developer", result: "done", projectSlug: "", summary: "" }) diff --git a/defaults/devclaw/prompts/tester.md b/defaults/devclaw/prompts/tester.md index 6fdcba53..177b64d4 100644 --- a/defaults/devclaw/prompts/tester.md +++ b/defaults/devclaw/prompts/tester.md @@ -1,10 +1,11 @@ # TESTER Worker Instructions -You test the deployed version and inspect code on the base branch. +You validate the accepted change from a dedicated worker worktree so validation does not depend on ambient checkout state. ## Your Job -- Pull latest from the base branch +- Start from the worker bootstrap contract in the task message +- Run validation from that dedicated worktree, not from an ambient repo checkout - Run tests and linting - Verify the changes address the issue requirements - Check for regressions in related functionality @@ -22,6 +23,16 @@ If you discover unrelated bugs or needed improvements during your work, call `ta `task_create({ projectSlug: "", title: "Bug: ...", description: "..." })` +## Validation classification + +Keep setup failures separate from product findings. + +- **environment/bootstrap failure**: worktree/bootstrap script failed, dependencies would not install, required tooling is missing, or the validation environment could not start +- **ambient validation noise**: repo baseline failures not caused by this issue +- **issue-local implementation failure**: the issue change itself breaks required behavior or validation + +If you block, include the category in your summary. + ## Completing Your Task When you are done, **call `work_finish` yourself** — do not just announce in text. diff --git a/dev/design/worker-worktree-bootstrap-contract.md b/dev/design/worker-worktree-bootstrap-contract.md new file mode 100644 index 00000000..9cfea1df --- /dev/null +++ b/dev/design/worker-worktree-bootstrap-contract.md @@ -0,0 +1,90 @@ +# Worker worktree bootstrap contract + +This design closes the gap between ordinary implementation work and the local checkout state that worker validation depends on. + +Related issues: +- #171, workers can validate different checkouts and silently disagree on results +- #174, canonical issue checkout contract and worktree lifecycle enforcement +- #238, bootstrap worker issue worktrees and separate environment failures from implementation blockers + +## Contract + +### 1. Dedicated worker worktrees are required + +Developer and tester validation must run from dedicated worker worktrees, not from an ambient checkout whose dependency state is unknown. + +### 2. Branch naming is canonical + +Default implementation branch: +- `issue/-` + +Feedback-cycle branch: +- reuse the existing PR branch exactly as given in PR feedback + +### 3. Dependency strategy is per-worktree install + +Each worker worktree provisions its own `node_modules`. + +Why: +- it avoids hidden dependence on some other checkout's install state +- it makes validation reproducible per worker +- it keeps the failure surface local to the worktree instead of leaking across branches + +### 4. Bootstrap is a first-class step + +Workers should use: +- `dev/scripts/bootstrap-issue-worktree.sh` + +The script: +- creates or reuses the canonical worktree +- reuses an existing local branch when present +- creates the branch from the configured base branch otherwise +- runs `npm install` when dependencies are missing or stale + +## Validation target + +Minimum developer handoff target: +- `npm run build` passes in the worker worktree + +Best-effort target: +- `npm run check` + +If the repository baseline is already noisy, workers should distinguish that noise from issue-local failures instead of misclassifying it as a product blocker for the issue. + +## Failure classes + +### environment/bootstrap failure + +Examples: +- worktree creation failure +- dependency install failure +- missing local tooling +- validation command cannot start because the local environment is incomplete + +These are setup problems. They should not silently present as issue-specific product failures. + +### ambient validation noise + +Examples: +- pre-existing repo-wide `npm run check` failures unrelated to the issue +- flaky unrelated checks already failing on the same base before the issue changes + +These should be reported explicitly as baseline noise. + +### issue-local implementation failure + +Examples: +- the issue's own changes fail `npm run build` +- the fix is incomplete +- the issue changes cause validation regressions in touched scope + +These are real implementation blockers. + +## Messaging rule + +Blocked or hold summaries should state which category applies: +- `environment/bootstrap failure` +- `ambient validation noise` +- `issue-local implementation failure` + +That keeps Refining or follow-up discussion focused on the real problem. diff --git a/dev/scripts/bootstrap-issue-worktree.sh b/dev/scripts/bootstrap-issue-worktree.sh new file mode 100755 index 00000000..de81acdf --- /dev/null +++ b/dev/scripts/bootstrap-issue-worktree.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -lt 4 ]]; then + echo "usage: $0 [branch-name]" >&2 + exit 2 +fi + +REPO_ROOT="$1" +ISSUE_ID="$2" +ISSUE_TITLE="$3" +BASE_BRANCH="$4" +BRANCH_NAME="${5:-}" + +slugify() { + printf '%s' "$1" \ + | tr '[:upper:]' '[:lower:]' \ + | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//; s/(.{48}).*/\1/' +} + +if [[ -z "$BRANCH_NAME" ]]; then + BRANCH_NAME="issue/${ISSUE_ID}-$(slugify "$ISSUE_TITLE")" +fi + +WORKTREE="${REPO_ROOT}.worktrees/${BRANCH_NAME}" +LOCKFILE="${WORKTREE}/package-lock.json" +NODE_MODULES="${WORKTREE}/node_modules" + +mkdir -p "$(dirname "$WORKTREE")" + +git -C "$REPO_ROOT" fetch origin "$BASE_BRANCH" >/dev/null 2>&1 || true + +if [[ -d "$WORKTREE/.git" || -f "$WORKTREE/.git" ]]; then + : +elif git -C "$REPO_ROOT" show-ref --verify --quiet "refs/heads/$BRANCH_NAME"; then + git -C "$REPO_ROOT" worktree add "$WORKTREE" "$BRANCH_NAME" +else + git -C "$REPO_ROOT" worktree add "$WORKTREE" -b "$BRANCH_NAME" "$BASE_BRANCH" +fi + +cd "$WORKTREE" + +if [[ ! -d "$NODE_MODULES" || ( -f "$LOCKFILE" && "$LOCKFILE" -nt "$NODE_MODULES" ) || package.json -nt "$NODE_MODULES" ]]; then + npm install +fi + +printf 'BOOTSTRAPPED_WORKTREE=%s\n' "$WORKTREE" +printf 'BOOTSTRAPPED_BRANCH=%s\n' "$BRANCH_NAME" diff --git a/lib/dispatch/message-builder.ts b/lib/dispatch/message-builder.ts index 1524f6d2..4ea06b54 100644 --- a/lib/dispatch/message-builder.ts +++ b/lib/dispatch/message-builder.ts @@ -3,6 +3,7 @@ */ import type { ResolvedRoleConfig } from "../config/index.js"; import { formatPrContext, formatPrFeedback, type PrContext, type PrFeedback } from "./pr-context.js"; +import { buildBootstrapContractSection } from "./worktree-bootstrap.js"; import { getFallbackEmoji } from "../roles/index.js"; /** @@ -85,6 +86,17 @@ export function buildTaskMessage(opts: { } if (opts.attachmentContext) parts.push(opts.attachmentContext); + if (role === "developer" || role === "tester") { + parts.push(...buildBootstrapContractSection({ + repo, + baseBranch, + role, + issueId, + issueTitle, + feedbackBranchName: opts.prFeedback?.branchName, + })); + } + parts.push( ``, `Repo: ${repo} | Branch: ${baseBranch} | ${issueUrl}`, diff --git a/lib/dispatch/worktree-bootstrap.test.ts b/lib/dispatch/worktree-bootstrap.test.ts new file mode 100644 index 00000000..e99bb8bd --- /dev/null +++ b/lib/dispatch/worktree-bootstrap.test.ts @@ -0,0 +1,34 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { buildIssueBranchName, buildWorktreePath, buildBootstrapContractSection } from "./worktree-bootstrap.js"; + +describe("worktree bootstrap contract", () => { + it("builds canonical issue branch names", () => { + assert.equal( + buildIssueBranchName(238, "Auto-bootstrap worker issue worktrees and separate environment/setup failures"), + "issue/238-auto-bootstrap-worker-issue-worktrees-and-separa" + ); + }); + + it("builds worktree path from repo and branch", () => { + assert.equal( + buildWorktreePath("/repo/devclaw", "issue/238-example"), + "/repo/devclaw.worktrees/issue/238-example" + ); + }); + + it("includes dependency strategy and failure classes", () => { + const section = buildBootstrapContractSection({ + repo: "/repo/devclaw", + baseBranch: "devclaw-local-dev", + role: "developer", + issueId: 238, + issueTitle: "Bootstrap worker issue worktrees", + }).join("\n"); + + assert.match(section, /per-worktree install/); + assert.match(section, /npm run build/); + assert.match(section, /ambient validation noise/); + assert.match(section, /environment\/bootstrap failure/); + }); +}); diff --git a/lib/dispatch/worktree-bootstrap.ts b/lib/dispatch/worktree-bootstrap.ts new file mode 100644 index 00000000..a23e13d9 --- /dev/null +++ b/lib/dispatch/worktree-bootstrap.ts @@ -0,0 +1,73 @@ +/** + * worktree-bootstrap.ts — Canonical worker checkout/bootstrap contract text. + */ + +function slugify(input: string): string { + return input + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 48) || "task"; +} + +export function buildIssueBranchName(issueId: number, issueTitle: string): string { + return `issue/${issueId}-${slugify(issueTitle)}`; +} + +export function buildWorktreePath(repo: string, branchName: string): string { + return `${repo}.worktrees/${branchName}`; +} + +export function buildBootstrapContractSection(opts: { + repo: string; + baseBranch: string; + role: string; + issueId: number; + issueTitle: string; + feedbackBranchName?: string; +}): string[] { + const branchName = opts.feedbackBranchName || buildIssueBranchName(opts.issueId, opts.issueTitle); + const worktreePath = buildWorktreePath(opts.repo, branchName); + const bootstrapScript = `${opts.repo}/dev/scripts/bootstrap-issue-worktree.sh`; + const isFeedbackCycle = !!opts.feedbackBranchName; + + const lines = [ + "", + "## Worker checkout and bootstrap contract", + "", + `- Required branch: \`${branchName}\``, + `- Required worktree: \`${worktreePath}\``, + "- Dependency strategy: per-worktree install. Each worker worktree must provision its own `node_modules` before validation.", + "- Bootstrap rule: run the bootstrap script below before validation. It creates or reuses the worktree and runs `npm install` when dependencies are missing or stale.", + "", + "```bash", + `${bootstrapScript} ${JSON.stringify(opts.repo)} ${opts.issueId} ${JSON.stringify(opts.issueTitle)} ${JSON.stringify(opts.baseBranch)}${isFeedbackCycle ? ` ${JSON.stringify(branchName)}` : ""}`, + "```", + "", + "After the script finishes, run validation from the bootstrapped worktree it prints.", + "", + "### Validation target", + "", + "- Required developer handoff target: `npm run build` passes in the worker worktree.", + "- Best-effort target: run `npm run check` from the same worktree.", + "- If `npm run check` fails because of pre-existing repo-wide noise unrelated to this issue, treat that as ambient validation noise, document it clearly, and do not silently convert it into an issue-local blocker.", + "", + "### Failure classification", + "", + "- `environment/bootstrap failure`: worktree creation failed, dependencies could not be installed, required local tooling is missing, or the validation environment cannot start.", + "- `ambient validation noise`: the repo baseline is already noisy, and the failing evidence is not caused by this issue's changes.", + "- `issue-local implementation failure`: the issue's own changes are incorrect, incomplete, or break required validation.", + "", + "If you must block, say which category applies in the `work_finish` summary so the issue is not misrouted.", + ]; + + if (opts.role === "tester") { + lines.splice( + 13, + 0, + "- Tester target: validate from a dedicated worker worktree too, not from an ambient repo checkout with unknown dependency state.", + ); + } + + return lines; +} From 66a805193f6dcd728936e90c608f16b82e3edd7a Mon Sep 17 00:00:00 2001 From: fujiwaranosai850 Date: Tue, 26 May 2026 19:21:57 +0000 Subject: [PATCH 20/30] fix: bootstrap issue branches from fetched base (#238) --- dev/scripts/bootstrap-issue-worktree.sh | 22 +++++- .../worktree-bootstrap-script.test.ts | 67 +++++++++++++++++++ 2 files changed, 87 insertions(+), 2 deletions(-) create mode 100644 lib/dispatch/worktree-bootstrap-script.test.ts diff --git a/dev/scripts/bootstrap-issue-worktree.sh b/dev/scripts/bootstrap-issue-worktree.sh index de81acdf..feee27b0 100755 --- a/dev/scripts/bootstrap-issue-worktree.sh +++ b/dev/scripts/bootstrap-issue-worktree.sh @@ -28,14 +28,32 @@ NODE_MODULES="${WORKTREE}/node_modules" mkdir -p "$(dirname "$WORKTREE")" -git -C "$REPO_ROOT" fetch origin "$BASE_BRANCH" >/dev/null 2>&1 || true +REMOTE_BASE_REF="refs/remotes/origin/${BASE_BRANCH}" +LOCAL_BASE_REF="refs/heads/${BASE_BRANCH}" +BASE_START_POINT="" + +if git -C "$REPO_ROOT" fetch origin "$BASE_BRANCH" >/dev/null 2>&1; then + if git -C "$REPO_ROOT" show-ref --verify --quiet "$REMOTE_BASE_REF"; then + BASE_START_POINT="origin/${BASE_BRANCH}" + fi +fi + +if [[ -z "$BASE_START_POINT" ]] && git -C "$REPO_ROOT" show-ref --verify --quiet "$LOCAL_BASE_REF"; then + BASE_START_POINT="$BASE_BRANCH" + printf 'bootstrap warning: using local %s because origin/%s is unavailable\n' "$BASE_BRANCH" "$BASE_BRANCH" >&2 +fi + +if [[ -z "$BASE_START_POINT" ]]; then + printf 'bootstrap error: could not resolve base branch %s from origin or local refs\n' "$BASE_BRANCH" >&2 + exit 1 +fi if [[ -d "$WORKTREE/.git" || -f "$WORKTREE/.git" ]]; then : elif git -C "$REPO_ROOT" show-ref --verify --quiet "refs/heads/$BRANCH_NAME"; then git -C "$REPO_ROOT" worktree add "$WORKTREE" "$BRANCH_NAME" else - git -C "$REPO_ROOT" worktree add "$WORKTREE" -b "$BRANCH_NAME" "$BASE_BRANCH" + git -C "$REPO_ROOT" worktree add "$WORKTREE" -b "$BRANCH_NAME" "$BASE_START_POINT" fi cd "$WORKTREE" diff --git a/lib/dispatch/worktree-bootstrap-script.test.ts b/lib/dispatch/worktree-bootstrap-script.test.ts new file mode 100644 index 00000000..1cb85e0c --- /dev/null +++ b/lib/dispatch/worktree-bootstrap-script.test.ts @@ -0,0 +1,67 @@ +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { execFileSync, execSync } from "node:child_process"; +import { afterEach, describe, it } from "node:test"; + +const SCRIPT = path.resolve("dev/scripts/bootstrap-issue-worktree.sh"); + +const cleanupPaths: string[] = []; + +afterEach(async () => { + while (cleanupPaths.length) { + const target = cleanupPaths.pop(); + if (!target) continue; + await fs.rm(target, { recursive: true, force: true }); + } +}); + +describe("bootstrap-issue-worktree.sh", () => { + it("creates new issue branches from fetched origin/", async () => { + const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-bootstrap-script-")); + cleanupPaths.push(tmp); + + const remote = path.join(tmp, "remote.git"); + const seed = path.join(tmp, "seed"); + const repo = path.join(tmp, "repo"); + await fs.mkdir(remote, { recursive: true }); + + execSync(`git init --bare ${JSON.stringify(remote)}`); + execSync(`git init ${JSON.stringify(seed)}`); + execSync(`git -C ${JSON.stringify(seed)} config user.name 'DevClaw Test'`); + execSync(`git -C ${JSON.stringify(seed)} config user.email 'devclaw@example.com'`); + await fs.writeFile(path.join(seed, "package.json"), '{"name":"bootstrap-test","version":"1.0.0"}\n'); + await fs.writeFile(path.join(seed, "package-lock.json"), '{"name":"bootstrap-test","lockfileVersion":3}\n'); + await fs.mkdir(path.join(seed, "node_modules"), { recursive: true }); + await fs.writeFile(path.join(seed, "node_modules", ".keep"), "ok\n"); + execSync(`git -C ${JSON.stringify(seed)} add package.json package-lock.json node_modules/.keep`); + execSync(`git -C ${JSON.stringify(seed)} commit -m 'seed base'`); + execSync(`git -C ${JSON.stringify(seed)} branch -M main`); + execSync(`git -C ${JSON.stringify(seed)} remote add origin ${JSON.stringify(remote)}`); + execSync(`git -C ${JSON.stringify(seed)} push origin main`); + + execSync(`git clone ${JSON.stringify(remote)} ${JSON.stringify(repo)}`); + execSync(`git -C ${JSON.stringify(repo)} checkout -b main origin/main`); + + const baseHead = execSync(`git -C ${JSON.stringify(repo)} rev-parse HEAD`, { encoding: "utf8" }).trim(); + + await fs.writeFile(path.join(seed, "README.md"), 'fresh upstream\n'); + execSync(`git -C ${JSON.stringify(seed)} add README.md`); + execSync(`git -C ${JSON.stringify(seed)} commit -m 'upstream advance'`); + execSync(`git -C ${JSON.stringify(seed)} push origin main`); + const originHead = execSync(`git -C ${JSON.stringify(seed)} rev-parse HEAD`, { encoding: "utf8" }).trim(); + assert.notEqual(originHead, baseHead); + + execFileSync(SCRIPT, [repo, "238", "Bootstrap worker issue worktrees", "main"], { + cwd: path.resolve("."), + encoding: "utf8", + }); + + const branchHead = execSync( + `git -C ${JSON.stringify(repo)} rev-parse refs/heads/issue/238-bootstrap-worker-issue-worktrees`, + { encoding: "utf8" }, + ).trim(); + assert.equal(branchHead, originHead); + }); +}); From b95d91d575744230640a29af2faa8489e5e0367e Mon Sep 17 00:00:00 2001 From: fujiwaranosai850 Date: Tue, 26 May 2026 20:50:40 +0000 Subject: [PATCH 21/30] feat: add shared deployer engine --- defaults/devclaw/workflow.yaml | 73 ++++++++++++ docs/ARCHITECTURE.md | 6 + docs/CONFIGURATION.md | 25 ++++ docs/TOOLS.md | 14 +++ index.ts | 4 +- lib/config/loader.ts | 7 +- lib/config/merge.ts | 32 ++++++ lib/config/schema.test.ts | 24 +++- lib/config/schema.ts | 99 +++++++++++++++- lib/config/types.ts | 60 ++++++++++ lib/deployer/engine.test.ts | 78 +++++++++++++ lib/deployer/engine.ts | 82 ++++++++++++++ lib/deployer/receipt.test.ts | 34 ++++++ lib/deployer/receipt.ts | 42 +++++++ lib/deployer/resolve.test.ts | 34 ++++++ lib/deployer/resolve.ts | 164 +++++++++++++++++++++++++++ lib/deployer/types.ts | 70 ++++++++++++ lib/deployer/workflow.ts | 43 +++++++ lib/tools/deployer/deploy-run.ts | 88 ++++++++++++++ lib/tools/worker/work-finish.test.ts | 11 +- lib/tools/worker/work-finish.ts | 24 +++- 21 files changed, 1002 insertions(+), 12 deletions(-) create mode 100644 lib/deployer/engine.test.ts create mode 100644 lib/deployer/engine.ts create mode 100644 lib/deployer/receipt.test.ts create mode 100644 lib/deployer/receipt.ts create mode 100644 lib/deployer/resolve.test.ts create mode 100644 lib/deployer/resolve.ts create mode 100644 lib/deployer/types.ts create mode 100644 lib/deployer/workflow.ts create mode 100644 lib/tools/deployer/deploy-run.ts diff --git a/defaults/devclaw/workflow.yaml b/defaults/devclaw/workflow.yaml index 5eee3573..4f9e3f50 100644 --- a/defaults/devclaw/workflow.yaml +++ b/defaults/devclaw/workflow.yaml @@ -245,3 +245,76 @@ workflow: color: "#f39c12" on: APPROVE: todo + +deployment: + lanes: + build: + aliases: [candidate] + description: Candidate built from merged work + legacyBranch: development + staging: + aliases: [stage] + rollbackTargets: [build] + legacyUrl: https://staging.example.com + production: + aliases: [prod] + humanOnly: true + protected: true + rollbackTargets: [staging] + legacyUrl: https://app.example.com + commands: + deploy-to-build: + run: echo "Deploy ${CANDIDATE_REF} to ${TARGET_LANE}" + promote-to-staging: + run: echo "Promote ${CANDIDATE_REF} from ${SOURCE_LANE} to ${TARGET_LANE}" + accept-on-staging: + run: echo "Accept ${CANDIDATE_REF} on ${TARGET_LANE}" + rollback-from-production: + run: echo "Rollback ${TARGET_LANE} to ${SOURCE_LANE} using ${CANDIDATE_REF}" + evidenceProfiles: + promotion: + required: [command, candidate] + commentSummary: true + acceptance: + required: [command, candidate] + commentSummary: true + rollback: + required: [command, candidate] + commentSummary: true + transitions: + - action: deploy + to: build + command: deploy-to-build + evidence: promotion + - action: promote + from: build + to: staging + command: promote-to-staging + evidence: promotion + - action: accept + from: staging + to: staging + command: accept-on-staging + evidence: acceptance + - action: rollback + from: staging + to: production + command: rollback-from-production + evidence: rollback + workflow: + states: + promoting: + action: promote + sourceLane: build + targetLane: staging + issueLinkage: workflow + accepting: + action: accept + sourceLane: staging + targetLane: staging + issueLinkage: workflow + candidate: + sources: [issueCandidate, issuePr, gitHead] + policy: + allowDirectWithoutIssue: true + requireHumanForProtectedLanes: true diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 250ec8ba..c74d3352 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -548,6 +548,12 @@ The source path is logged for production traceability: `Bootstrap hook: injected The Deployer uses a dedicated `deployer.md` prompt surface. +Delivery execution now runs through one shared deployer engine: +- workflow-backed delivery states call a thin workflow wrapper +- direct operational deploys call a thin `deploy_run` tool wrapper +- both paths resolve lanes, transitions, candidate identity, commands, and evidence from `deployment:` config +- both paths emit the same durable deploy receipt shape + ## Data flow map Every piece of data and where it lives: diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 311aa232..5ce1add1 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -100,6 +100,31 @@ Release-agent configuration should also define: - required release evidence or proof receipts - retry and override behavior for repeated promotions +The first-class `deployment:` block is the semantic source of truth for lanes, transitions, commands, workflow-state mapping, candidate resolution order, and direct-deploy policy. + +```yaml +deployment: + lanes: + build: { aliases: [candidate] } + staging: { aliases: [stage], rollbackTargets: [build] } + commands: + promote-to-staging: + run: echo "Promote ${CANDIDATE_REF} from ${SOURCE_LANE} to ${TARGET_LANE}" + transitions: + - action: promote + from: build + to: staging + command: promote-to-staging + workflow: + states: + promoting: + action: promote + sourceLane: build + targetLane: staging +``` + +Legacy project metadata like `deployBranch` and `deployUrl` still acts as a fallback default, but it is no longer the semantic source of truth. + For the operator-facing contract, see [`../dev/design/deployer-contract.md`](../dev/design/deployer-contract.md). ### Timeouts diff --git a/docs/TOOLS.md b/docs/TOOLS.md index 49cfd6d2..ee82c276 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -336,6 +336,20 @@ One-time project setup. Creates state labels, scaffolds project directory with o | `deployUrl` | string | No | Deployment URL | | `roleExecution` | `"parallel"` \| `"sequential"` | No | DEVELOPER/TESTER parallelism. Default: `"parallel"`. | +### `deploy_run` + +Direct operational deploy entrypoint backed by the shared deployer engine. + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `channelId` | string | Yes | Current chat/group ID | +| `action` | `promote` \| `accept` \| `rollback` | Yes | Deploy action | +| `targetLane` | string | Yes | Canonical lane or alias | +| `sourceLane` | string | No | Source lane or alias | +| `candidateRef` | string | No | Explicit candidate identity | +| `issueId` | number | No | Optional issue linkage for comment receipts | +| `dryRun` | boolean | No | Resolve and receipt without executing the command | + **What it does atomically:** 1. Validates project not already registered diff --git a/index.ts b/index.ts index 4d945fe7..84540409 100644 --- a/index.ts +++ b/index.ts @@ -34,6 +34,7 @@ import { createOnboardTool } from "./lib/tools/admin/onboard.js"; import { createAutoConfigureModelsTool } from "./lib/tools/admin/autoconfigure-models.js"; import { createWorkflowGuideTool } from "./lib/tools/admin/workflow-guide.js"; import { createConfigTool } from "./lib/tools/admin/config.js"; +import { createDeployRunTool } from "./lib/tools/deployer/deploy-run.js"; // Infrastructure import { registerCli } from "./lib/setup/cli.js"; @@ -128,6 +129,7 @@ const plugin = { api.registerTool(createAutoConfigureModelsTool(ctx), { names: ["autoconfigure_models"] }); api.registerTool(createWorkflowGuideTool(ctx), { names: ["workflow_guide"] }); api.registerTool(createConfigTool(ctx), { names: ["config"] }); + api.registerTool(createDeployRunTool(ctx), { names: ["deploy_run"] }); // CLI, services & hooks api.registerCli(({ program }: { program: any }) => registerCli(program, ctx), { @@ -138,7 +140,7 @@ const plugin = { registerAttachmentHook(api, ctx); api.logger.info( - `DevClaw plugin registered (24 tools, 1 CLI command group, 1 service, 3 hooks) | build=${formatBuildProvenanceSummary(provenance)} | provenance=${JSON.stringify(provenance)}`, + `DevClaw plugin registered (25 tools, 1 CLI command group, 1 service, 3 hooks) | build=${formatBuildProvenanceSummary(provenance)} | provenance=${JSON.stringify(provenance)}`, ); }, }; diff --git a/lib/config/loader.ts b/lib/config/loader.ts index 79aa0506..ae10f485 100644 --- a/lib/config/loader.ts +++ b/lib/config/loader.ts @@ -85,7 +85,7 @@ function buildDefaultConfig(): DevClawConfig { completionResults: [...reg.completionResults], }; } - return { roles, workflow: DEFAULT_WORKFLOW }; + return { roles, workflow: DEFAULT_WORKFLOW, deployment: {} }; } /** @@ -209,7 +209,10 @@ function resolve(config: DevClawConfig): ResolvedConfig { }; return { - roles, workflow, timeouts, + roles, + workflow, + deployment: config.deployment ?? {}, + timeouts, instanceName: config.instance?.name, }; } diff --git a/lib/config/merge.ts b/lib/config/merge.ts index 00957053..c0f2cf6f 100644 --- a/lib/config/merge.ts +++ b/lib/config/merge.ts @@ -73,6 +73,38 @@ export function mergeConfig( } } + if (base.deployment || overlay.deployment) { + merged.deployment = { + ...base.deployment, + ...overlay.deployment, + lanes: base.deployment?.lanes || overlay.deployment?.lanes + ? { ...base.deployment?.lanes, ...overlay.deployment?.lanes } + : undefined, + commands: base.deployment?.commands || overlay.deployment?.commands + ? { ...base.deployment?.commands, ...overlay.deployment?.commands } + : undefined, + evidenceProfiles: base.deployment?.evidenceProfiles || overlay.deployment?.evidenceProfiles + ? { ...base.deployment?.evidenceProfiles, ...overlay.deployment?.evidenceProfiles } + : undefined, + workflow: base.deployment?.workflow || overlay.deployment?.workflow + ? { + ...base.deployment?.workflow, + ...overlay.deployment?.workflow, + states: base.deployment?.workflow?.states || overlay.deployment?.workflow?.states + ? { ...base.deployment?.workflow?.states, ...overlay.deployment?.workflow?.states } + : undefined, + } + : undefined, + candidate: base.deployment?.candidate || overlay.deployment?.candidate + ? { ...base.deployment?.candidate, ...overlay.deployment?.candidate } + : undefined, + policy: base.deployment?.policy || overlay.deployment?.policy + ? { ...base.deployment?.policy, ...overlay.deployment?.policy } + : undefined, + transitions: overlay.deployment?.transitions ?? base.deployment?.transitions, + }; + } + // Merge timeouts if (base.timeouts || overlay.timeouts) { merged.timeouts = { ...base.timeouts, ...overlay.timeouts }; diff --git a/lib/config/schema.test.ts b/lib/config/schema.test.ts index 1ec01450..d052756d 100644 --- a/lib/config/schema.test.ts +++ b/lib/config/schema.test.ts @@ -1,6 +1,6 @@ import { describe, it } from "node:test"; import assert from "node:assert"; -import { validateWorkflowIntegrity } from "./schema.js"; +import { validateConfig, validateWorkflowIntegrity } from "./schema.js"; import { DEFAULT_WORKFLOW } from "../workflow/index.js"; describe("validateWorkflowIntegrity delivery role validation", () => { @@ -26,3 +26,25 @@ describe("validateWorkflowIntegrity delivery role validation", () => { assert.ok(errors.includes("workflow.delivery.acceptance.activeState must reference a deployer-owned state")); }); }); + +describe("deployment config validation", () => { + it("rejects duplicate lane aliases", () => { + assert.throws(() => validateConfig({ + deployment: { + lanes: { + one: { aliases: ["prod"] }, + two: { aliases: ["prod"] }, + }, + }, + }), /alias "prod" is used by both/); + }); + + it("rejects transition references to missing commands", () => { + assert.throws(() => validateConfig({ + deployment: { + lanes: { build: {}, staging: {} }, + transitions: [{ action: "promote", from: "build", to: "staging", command: "missing" }], + }, + }), /command "missing" does not exist/); + }); +}); diff --git a/lib/config/schema.ts b/lib/config/schema.ts index e5af1754..87bcd1e0 100644 --- a/lib/config/schema.ts +++ b/lib/config/schema.ts @@ -78,6 +78,58 @@ const TimeoutConfigSchema = z.object({ sessionContextBudget: z.number().min(0).max(1).optional(), }).optional(); +const DeploymentLaneSchema = z.object({ + aliases: z.array(z.string()).optional(), + description: z.string().optional(), + humanOnly: z.boolean().optional(), + protected: z.boolean().optional(), + rollbackTargets: z.array(z.string()).optional(), + legacyBranch: z.string().optional(), + legacyUrl: z.string().optional(), +}); + +const DeploymentCommandSchema = z.object({ + run: z.string(), + cwd: z.string().optional(), + timeoutMs: z.number().positive().optional(), +}); + +const DeploymentEvidenceProfileSchema = z.object({ + required: z.array(z.string()).optional(), + commentSummary: z.boolean().optional(), +}); + +const DeploymentTransitionSchema = z.object({ + action: z.enum(["deploy", "promote", "accept", "rollback"]), + from: z.string().optional(), + to: z.string(), + command: z.string(), + evidence: z.string().optional(), + requireCandidate: z.boolean().optional(), +}); + +const DeploymentSchema = z.object({ + lanes: z.record(z.string(), DeploymentLaneSchema).optional(), + commands: z.record(z.string(), DeploymentCommandSchema).optional(), + evidenceProfiles: z.record(z.string(), DeploymentEvidenceProfileSchema).optional(), + transitions: z.array(DeploymentTransitionSchema).optional(), + workflow: z.object({ + states: z.record(z.string(), z.object({ + action: z.enum(["deploy", "promote", "accept", "rollback"]), + targetLane: z.string(), + sourceLane: z.string().optional(), + issueLinkage: z.enum(["none", "comment", "workflow"]).optional(), + })).optional(), + }).optional(), + candidate: z.object({ + sources: z.array(z.enum(["explicit", "issueCandidate", "issuePr", "gitHead"])) .optional(), + }).optional(), + policy: z.object({ + allowDirectWithoutIssue: z.boolean().optional(), + requireHumanForProtectedLanes: z.boolean().optional(), + }).optional(), +}).optional(); + const InstanceConfigSchema = z.object({ name: z.string().optional(), }).optional(); @@ -85,6 +137,7 @@ const InstanceConfigSchema = z.object({ export const DevClawConfigSchema = z.object({ roles: z.record(z.string(), RoleOverrideSchema).optional(), workflow: WorkflowConfigSchema.partial().optional(), + deployment: DeploymentSchema, timeouts: TimeoutConfigSchema, instance: InstanceConfigSchema, }); @@ -94,7 +147,51 @@ export const DevClawConfigSchema = z.object({ * Returns the validated config or throws with a descriptive error. */ export function validateConfig(raw: unknown): void { - DevClawConfigSchema.parse(raw); + const parsed = DevClawConfigSchema.parse(raw); + const deployment = parsed.deployment; + if (!deployment) return; + + const laneKeys = new Set(Object.keys(deployment.lanes ?? {})); + const aliases = new Map(); + for (const [lane, cfg] of Object.entries(deployment.lanes ?? {})) { + for (const alias of [lane, ...(cfg.aliases ?? [])]) { + const normalized = alias.trim().toLowerCase(); + const existing = aliases.get(normalized); + if (existing && existing !== lane) { + throw new Error(`Invalid deployment config: alias "${alias}" is used by both "${existing}" and "${lane}"`); + } + aliases.set(normalized, lane); + } + for (const rollbackTarget of cfg.rollbackTargets ?? []) { + if (!laneKeys.has(rollbackTarget)) { + throw new Error(`Invalid deployment config: lane "${lane}" rollback target "${rollbackTarget}" does not exist`); + } + } + } + + for (const transition of deployment.transitions ?? []) { + if (transition.from && !laneKeys.has(transition.from)) { + throw new Error(`Invalid deployment config: transition from lane "${transition.from}" does not exist`); + } + if (!laneKeys.has(transition.to)) { + throw new Error(`Invalid deployment config: transition to lane "${transition.to}" does not exist`); + } + if (!deployment.commands?.[transition.command]) { + throw new Error(`Invalid deployment config: transition command "${transition.command}" does not exist`); + } + if (transition.evidence && !deployment.evidenceProfiles?.[transition.evidence]) { + throw new Error(`Invalid deployment config: transition evidence profile "${transition.evidence}" does not exist`); + } + } + + for (const [stateKey, stateCfg] of Object.entries(deployment.workflow?.states ?? {})) { + if (!laneKeys.has(stateCfg.targetLane)) { + throw new Error(`Invalid deployment config: workflow state "${stateKey}" target lane "${stateCfg.targetLane}" does not exist`); + } + if (stateCfg.sourceLane && !laneKeys.has(stateCfg.sourceLane)) { + throw new Error(`Invalid deployment config: workflow state "${stateKey}" source lane "${stateCfg.sourceLane}" does not exist`); + } + } } /** diff --git a/lib/config/types.ts b/lib/config/types.ts index 4efa67be..77a44b6a 100644 --- a/lib/config/types.ts +++ b/lib/config/types.ts @@ -6,6 +6,64 @@ */ import type { WorkflowConfig } from "../workflow/index.js"; +export type DeploymentAction = "deploy" | "promote" | "accept" | "rollback"; +export type CandidateSource = "explicit" | "issueCandidate" | "issuePr" | "gitHead"; +export type IssueLinkageMode = "none" | "comment" | "workflow"; + +export type DeploymentLaneConfig = { + aliases?: string[]; + description?: string; + humanOnly?: boolean; + protected?: boolean; + rollbackTargets?: string[]; + legacyBranch?: string; + legacyUrl?: string; +}; + +export type DeploymentCommandConfig = { + run: string; + cwd?: string; + timeoutMs?: number; +}; + +export type DeploymentEvidenceProfile = { + required?: string[]; + commentSummary?: boolean; +}; + +export type DeploymentTransitionConfig = { + action: DeploymentAction; + from?: string; + to: string; + command: string; + evidence?: string; + requireCandidate?: boolean; +}; + +export type DeploymentWorkflowStateConfig = { + action: DeploymentAction; + targetLane: string; + sourceLane?: string; + issueLinkage?: IssueLinkageMode; +}; + +export type DeploymentConfig = { + lanes?: Record; + commands?: Record; + evidenceProfiles?: Record; + transitions?: DeploymentTransitionConfig[]; + workflow?: { + states?: Record; + }; + candidate?: { + sources?: CandidateSource[]; + }; + policy?: { + allowDirectWithoutIssue?: boolean; + requireHumanForProtectedLanes?: boolean; + }; +}; + /** * Role override in workflow.yaml. All fields optional — only override what you need. * Set to `false` to disable a role entirely for a project. @@ -53,6 +111,7 @@ export type InstanceConfig = { export type DevClawConfig = { roles?: Record; workflow?: Partial; + deployment?: DeploymentConfig; timeouts?: TimeoutConfig; instance?: InstanceConfig; }; @@ -79,6 +138,7 @@ export type ResolvedTimeouts = { export type ResolvedConfig = { roles: Record; workflow: WorkflowConfig; + deployment: DeploymentConfig; timeouts: ResolvedTimeouts; /** Instance name override from config. Undefined = use auto-generated from instance.json. */ instanceName?: string; diff --git a/lib/deployer/engine.test.ts b/lib/deployer/engine.test.ts new file mode 100644 index 00000000..7d8d3bea --- /dev/null +++ b/lib/deployer/engine.test.ts @@ -0,0 +1,78 @@ +import { describe, it } from "node:test"; +import assert from "node:assert"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { runDeployEngine } from "./engine.js"; +import { runWorkflowDeployment } from "./workflow.js"; +import { TestProvider } from "../testing/test-provider.js"; +import type { DeploymentConfig } from "../config/types.js"; + +const deployment: DeploymentConfig = { + lanes: { + build: { aliases: ["candidate"] }, + staging: { aliases: ["stage"], rollbackTargets: ["build"] }, + }, + commands: { + promote: { run: 'echo "promote ${CANDIDATE_REF} ${SOURCE_LANE} ${TARGET_LANE}"' }, + }, + evidenceProfiles: { + proof: { required: ["command", "candidate"], commentSummary: true }, + }, + transitions: [ + { action: "promote", from: "build", to: "staging", command: "promote", evidence: "proof" }, + ], + workflow: { + states: { + promoting: { action: "promote", sourceLane: "build", targetLane: "staging", issueLinkage: "workflow" }, + }, + }, + candidate: { sources: ["explicit"] }, + policy: { allowDirectWithoutIssue: true }, +}; + +describe("deploy engine", () => { + it("returns normalized receipt fields for direct and workflow runs", async () => { + const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-deploy-engine-")); + const provider = new TestProvider(); + provider.seedIssue({ iid: 7, labels: ["Promoting"] }); + const runCommand = async () => ({ stdout: "ok\n", stderr: "", code: 0, signal: null, killed: false as const }); + const project = { name: "demo", deployBranch: "main", deployUrl: "", repo: "/tmp/repo" } as any; + + const direct = await runDeployEngine({ + workspaceDir, + project, + repoPath: "/tmp/repo", + config: deployment, + provider, + runCommand: runCommand as any, + request: { + action: "promote", + sourceLane: "build", + targetLane: "staging", + candidateRef: "sha123", + issueId: 7, + issueLinkage: "comment", + invocation: { kind: "direct" }, + }, + }); + + const workflow = await runWorkflowDeployment({ + workspaceDir, + project, + repoPath: "/tmp/repo", + provider, + issueId: 7, + currentStateKey: "promoting", + config: deployment, + runCommand: runCommand as any, + }); + + assert.equal(direct.receipt.action, workflow.receipt.action); + assert.equal(direct.receipt.targetLane, workflow.receipt.targetLane); + assert.equal(direct.receipt.transitionKey, workflow.receipt.transitionKey); + assert.equal(direct.receipt.candidate?.ref, "sha123"); + assert.equal(workflow.receipt.issueLinkage, "workflow"); + assert.ok(workflow.receipt.linkedIssueCommentId); + }); +}); diff --git a/lib/deployer/engine.ts b/lib/deployer/engine.ts new file mode 100644 index 00000000..04b51d51 --- /dev/null +++ b/lib/deployer/engine.ts @@ -0,0 +1,82 @@ +import type { Project } from "../projects/index.js"; +import type { RunCommand } from "../context.js"; +import type { DeploymentConfig } from "../config/types.js"; +import type { IssueProvider } from "../providers/provider.js"; +import { writeDeployReceipt } from "./receipt.js"; +import { resolveDeployDecision } from "./resolve.js"; +import type { DeployEngineResult, DeployReceipt, DeployRequest } from "./types.js"; + +function interpolate(template: string, vars: Record): string { + return template.replace(/\$\{([A-Z_]+)\}/g, (_m, key) => vars[key] ?? ""); +} + +export async function runDeployEngine(opts: { + workspaceDir: string; + project: Project; + repoPath: string; + config: DeploymentConfig; + request: DeployRequest; + runCommand: RunCommand; + provider?: IssueProvider; +}): Promise { + const decision = await resolveDeployDecision({ + request: opts.request, + config: opts.config, + project: opts.project, + provider: opts.provider, + repoPath: opts.repoPath, + runCommand: opts.runCommand, + }); + + const vars = { + ACTION: decision.action, + SOURCE_LANE: decision.sourceLane ?? "", + TARGET_LANE: decision.targetLane, + CANDIDATE_REF: decision.candidate?.ref ?? "", + ISSUE_ID: String(opts.request.issueId ?? ""), + PROJECT: opts.project.name, + }; + + const command = interpolate(decision.command, vars); + let stdout = ""; + let stderr = ""; + let exitCode = 0; + + if (!opts.request.dryRun) { + const result = await opts.runCommand(["bash", "-lc", command], { + cwd: decision.cwd ?? opts.repoPath, + timeoutMs: decision.timeoutMs, + }); + stdout = result.stdout ?? ""; + stderr = result.stderr ?? ""; + exitCode = result.code ?? 0; + } + + const receipt: DeployReceipt = { + id: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`, + timestamp: new Date().toISOString(), + project: opts.project.name, + issueId: opts.request.issueId, + invocation: opts.request.invocation, + action: decision.action, + issueLinkage: decision.issueLinkage, + sourceLane: decision.sourceLane, + targetLane: decision.targetLane, + transitionKey: decision.transitionKey, + candidate: decision.candidate, + commandId: decision.commandId, + command, + cwd: decision.cwd ?? opts.repoPath, + dryRun: !!opts.request.dryRun, + exitCode, + stdout, + stderr, + success: exitCode === 0, + evidenceProfile: decision.evidenceProfile, + evidence: opts.config.evidenceProfiles?.[decision.evidenceProfile ?? ""]?.required ?? [], + configSnapshot: { policy: opts.config.policy }, + }; + + receipt.receiptPath = await writeDeployReceipt(opts.workspaceDir, opts.project.name, receipt); + return { decision, receipt }; +} diff --git a/lib/deployer/receipt.test.ts b/lib/deployer/receipt.test.ts new file mode 100644 index 00000000..3fab35e1 --- /dev/null +++ b/lib/deployer/receipt.test.ts @@ -0,0 +1,34 @@ +import { describe, it } from "node:test"; +import assert from "node:assert"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { writeDeployReceipt } from "./receipt.js"; + +describe("deploy receipts", () => { + it("writes a durable receipt file", async () => { + const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-deploy-")); + const receiptPath = await writeDeployReceipt(workspaceDir, "demo", { + id: "r1", + timestamp: new Date().toISOString(), + project: "demo", + invocation: { kind: "direct" }, + action: "promote", + issueLinkage: "none", + sourceLane: "build", + targetLane: "staging", + transitionKey: "promote:build->staging", + candidate: { ref: "abc", source: "explicit" }, + commandId: "cmd", + command: "echo ok", + dryRun: true, + exitCode: 0, + stdout: "", + stderr: "", + success: true, + evidence: ["command"], + }); + const content = await fs.readFile(receiptPath, "utf-8"); + assert.match(content, /"transitionKey": "promote:build->staging"/); + }); +}); diff --git a/lib/deployer/receipt.ts b/lib/deployer/receipt.ts new file mode 100644 index 00000000..a5b3f616 --- /dev/null +++ b/lib/deployer/receipt.ts @@ -0,0 +1,42 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { DATA_DIR } from "../setup/migrate-layout.js"; +import { log as auditLog } from "../audit.js"; +import type { DeployReceipt } from "./types.js"; + +export async function writeDeployReceipt(workspaceDir: string, projectName: string, receipt: DeployReceipt): Promise { + const dir = join(workspaceDir, DATA_DIR, "deploy", projectName); + await mkdir(dir, { recursive: true }); + const filePath = join(dir, `${receipt.timestamp.replace(/[:.]/g, "-")}-${receipt.id}.json`); + await writeFile(filePath, JSON.stringify(receipt, null, 2) + "\n", "utf-8"); + await auditLog(workspaceDir, "deploy_receipt", { + project: projectName, + issueId: receipt.issueId ?? null, + receiptId: receipt.id, + action: receipt.action, + targetLane: receipt.targetLane, + sourceLane: receipt.sourceLane, + success: receipt.success, + receiptPath: filePath, + invocation: receipt.invocation.kind, + }); + return filePath; +} + +export function renderDeployReceiptSummary(receipt: DeployReceipt): string { + const lines = [ + "## DevClaw Deploy Receipt", + "", + `- receipt: ${receipt.id}`, + `- action: ${receipt.action}`, + `- transition: ${receipt.sourceLane ?? "*"} -> ${receipt.targetLane}`, + `- candidate: ${receipt.candidate?.ref ?? "none"}`, + `- invocation: ${receipt.invocation.kind}`, + `- linkage: ${receipt.issueLinkage}`, + `- result: ${receipt.success ? "success" : "failed"}`, + ]; + if (receipt.receiptPath) lines.push(`- receiptPath: ${receipt.receiptPath}`); + if (receipt.stdout.trim()) lines.push(`- stdout: \`${receipt.stdout.trim().slice(0, 200)}\``); + if (receipt.stderr.trim()) lines.push(`- stderr: \`${receipt.stderr.trim().slice(0, 200)}\``); + return lines.join("\n"); +} diff --git a/lib/deployer/resolve.test.ts b/lib/deployer/resolve.test.ts new file mode 100644 index 00000000..da77eb6c --- /dev/null +++ b/lib/deployer/resolve.test.ts @@ -0,0 +1,34 @@ +import { describe, it } from "node:test"; +import assert from "node:assert"; +import { resolveLaneAlias, validateRollbackLegality, withLegacyDeploymentDefaults } from "./resolve.js"; + +const config = { + lanes: { + build: { aliases: ["candidate"] }, + staging: { aliases: ["stage"], rollbackTargets: ["build"] }, + production: { aliases: ["prod"], rollbackTargets: ["staging"] }, + }, + commands: {}, + transitions: [], +}; + +describe("deployer resolve helpers", () => { + it("resolves lane aliases", () => { + assert.equal(resolveLaneAlias(config, "stage"), "staging"); + assert.equal(resolveLaneAlias(config, "production"), "production"); + assert.equal(resolveLaneAlias(config, "missing"), null); + }); + + it("validates rollback legality", () => { + assert.doesNotThrow(() => validateRollbackLegality(config, "build", "staging")); + assert.throws(() => validateRollbackLegality(config, "build", "production"), /not allowed/); + }); + + it("backfills legacy deployment defaults", () => { + const legacy = withLegacyDeploymentDefaults({}, { name: "p", deployBranch: "main", deployUrl: "https://example.com" } as any); + assert.ok(legacy.transitions?.some((transition) => transition.action === "deploy")); + assert.ok(legacy.commands?.legacyDeploy); + assert.ok(legacy.commands?.legacyPromote); + assert.ok(legacy.lanes?.default); + }); +}); diff --git a/lib/deployer/resolve.ts b/lib/deployer/resolve.ts new file mode 100644 index 00000000..a13d3923 --- /dev/null +++ b/lib/deployer/resolve.ts @@ -0,0 +1,164 @@ +import type { Project } from "../projects/index.js"; +import type { RunCommand } from "../context.js"; +import type { DeploymentConfig, DeploymentAction } from "../config/types.js"; +import type { IssueProvider } from "../providers/provider.js"; +import { getCurrentCandidate } from "../workflow/index.js"; +import type { DeployCandidate, DeployDecision, DeployRequest } from "./types.js"; + +export function withLegacyDeploymentDefaults(config: DeploymentConfig, project: Project): DeploymentConfig { + if ((config.transitions?.length ?? 0) > 0) return config; + return { + ...config, + lanes: { + default: { + aliases: ["legacy", project.deployBranch || "deploy"], + legacyBranch: project.deployBranch || undefined, + legacyUrl: project.deployUrl || undefined, + }, + ...(config.lanes ?? {}), + }, + commands: { + legacyDeploy: { run: `echo \"Legacy deploy ${"${CANDIDATE_REF}"} to ${"${TARGET_LANE}"} (${project.deployBranch || "unknown-branch"}) ${project.deployUrl || ""}\"` }, + legacyPromote: { run: `echo \"Legacy promote ${"${CANDIDATE_REF}"} to ${"${TARGET_LANE}"} (${project.deployBranch || "unknown-branch"}) ${project.deployUrl || ""}\"` }, + legacyAccept: { run: `echo \"Legacy accept ${"${CANDIDATE_REF}"} on ${"${TARGET_LANE}"}\"` }, + legacyRollback: { run: `echo \"Legacy rollback ${"${CANDIDATE_REF}"} to ${"${TARGET_LANE}"}\"` }, + ...(config.commands ?? {}), + }, + evidenceProfiles: { + legacy: { required: ["command", "candidate"], commentSummary: true }, + ...(config.evidenceProfiles ?? {}), + }, + transitions: [ + { action: "deploy", to: "default", command: "legacyDeploy", evidence: "legacy" }, + { action: "promote", to: "default", command: "legacyPromote", evidence: "legacy" }, + { action: "accept", to: "default", command: "legacyAccept", evidence: "legacy" }, + { action: "rollback", to: "default", command: "legacyRollback", evidence: "legacy" }, + ...(config.transitions ?? []), + ], + }; +} + +export function resolveLaneAlias(config: DeploymentConfig, laneOrAlias: string | undefined): string | null { + if (!laneOrAlias) return null; + const normalized = laneOrAlias.trim().toLowerCase(); + for (const [lane, cfg] of Object.entries(config.lanes ?? {})) { + if (lane.toLowerCase() === normalized) return lane; + if ((cfg.aliases ?? []).some(alias => alias.trim().toLowerCase() === normalized)) return lane; + } + return null; +} + +export function selectTransition(config: DeploymentConfig, action: DeploymentAction, sourceLane: string | null, targetLane: string) { + const match = (config.transitions ?? []).find((transition) => transition.action === action && transition.to === targetLane && (transition.from == null || transition.from === sourceLane)); + if (!match) throw new Error(`No deployment transition configured for ${action} ${sourceLane ?? "*"} -> ${targetLane}`); + return match; +} + +export function validateRollbackLegality(config: DeploymentConfig, sourceLane: string | null, targetLane: string): void { + if (!sourceLane) return; + const allowed = config.lanes?.[targetLane]?.rollbackTargets ?? []; + if (allowed.length > 0 && !allowed.includes(sourceLane)) { + throw new Error(`Rollback from ${targetLane} to ${sourceLane} is not allowed by deployment config`); + } +} + +export function getEvidenceProfile(config: DeploymentConfig, profileName?: string): string[] { + return config.evidenceProfiles?.[profileName ?? ""]?.required ?? []; +} + +export async function normalizeCandidate(opts: { + request: DeployRequest; + config: DeploymentConfig; + provider?: IssueProvider; + repoPath: string; + runCommand: RunCommand; +}): Promise { + const { request, config, provider, repoPath, runCommand } = opts; + const sources = config.candidate?.sources ?? ["explicit", "issueCandidate", "issuePr", "gitHead"]; + + for (const source of sources) { + if (source === "explicit" && request.candidateRef) { + return { ref: request.candidateRef, source: "explicit" }; + } + if (source === "issueCandidate" && provider && request.issueId) { + const current = await getCurrentCandidate(provider, request.issueId); + if (current?.candidateId || current?.commitSha) { + return { + ref: current.candidateId ?? current.commitSha ?? "unknown-candidate", + source: "issueCandidate", + commitSha: current.commitSha, + prUrl: current.prUrl, + }; + } + } + if (source === "issuePr" && provider && request.issueId) { + const pr = await provider.getPrStatus(request.issueId).catch(() => null); + if (pr?.url) { + return { ref: pr.sourceBranch ?? pr.url, source: "issuePr", prUrl: pr.url }; + } + } + if (source === "gitHead") { + const head = await runCommand(["git", "rev-parse", "HEAD"], { cwd: repoPath, timeoutMs: 10_000 }).catch(() => null); + const sha = head?.stdout?.trim(); + if (sha) return { ref: sha, source: "gitHead", commitSha: sha }; + } + } + + return null; +} + +export async function resolveDeployDecision(opts: { + request: DeployRequest; + config: DeploymentConfig; + project: Project; + provider?: IssueProvider; + repoPath: string; + runCommand: RunCommand; +}): Promise { + const config = withLegacyDeploymentDefaults(opts.config, opts.project); + const targetLane = resolveLaneAlias(config, opts.request.targetLane); + if (!targetLane) throw new Error(`Unknown deployment lane: ${opts.request.targetLane}`); + const sourceLane = resolveLaneAlias(config, opts.request.sourceLane) ?? null; + + if (opts.request.action === "rollback") validateRollbackLegality(config, sourceLane, targetLane); + + const laneCfg = config.lanes?.[targetLane]; + if (laneCfg?.humanOnly || (laneCfg?.protected && config.policy?.requireHumanForProtectedLanes)) { + if (opts.request.invocation.kind === "direct") { + throw new Error(`Lane ${targetLane} is human-only for direct deploys`); + } + } + + if (opts.request.invocation.kind === "direct" && !opts.request.issueId && config.policy?.allowDirectWithoutIssue === false) { + throw new Error("Direct deployment without an issue is disabled by policy"); + } + + const transition = selectTransition(config, opts.request.action, sourceLane, targetLane); + const commandCfg = config.commands?.[transition.command]; + if (!commandCfg) throw new Error(`Deployment command ${transition.command} is not configured`); + + const candidate = await normalizeCandidate({ + request: opts.request, + config, + provider: opts.provider, + repoPath: opts.repoPath, + runCommand: opts.runCommand, + }); + if ((transition.requireCandidate ?? true) && !candidate) { + throw new Error("Deployment candidate identity is required but could not be resolved"); + } + + return { + action: opts.request.action, + sourceLane, + targetLane, + transitionKey: `${opts.request.action}:${sourceLane ?? "*"}->${targetLane}`, + commandId: transition.command, + command: commandCfg.run, + cwd: commandCfg.cwd, + timeoutMs: commandCfg.timeoutMs ?? 600_000, + evidenceProfile: transition.evidence, + candidate, + issueLinkage: opts.request.issueLinkage ?? (opts.request.invocation.kind === "workflow" ? "workflow" : opts.request.issueId ? "comment" : "none"), + }; +} diff --git a/lib/deployer/types.ts b/lib/deployer/types.ts new file mode 100644 index 00000000..a9f11104 --- /dev/null +++ b/lib/deployer/types.ts @@ -0,0 +1,70 @@ +import type { DeploymentAction, DeploymentConfig, IssueLinkageMode } from "../config/types.js"; + +export type DeployInvocationKind = "workflow" | "direct"; + +export type DeployRequest = { + action: DeploymentAction; + targetLane: string; + sourceLane?: string; + candidateRef?: string; + dryRun?: boolean; + issueId?: number; + issueLinkage?: IssueLinkageMode; + invocation: { + kind: DeployInvocationKind; + stateKey?: string; + }; +}; + +export type DeployCandidate = { + ref: string; + source: "explicit" | "issueCandidate" | "issuePr" | "gitHead" | "fallback"; + commitSha?: string | null; + prUrl?: string | null; +}; + +export type DeployDecision = { + action: DeploymentAction; + sourceLane: string | null; + targetLane: string; + transitionKey: string; + commandId: string; + command: string; + cwd?: string; + timeoutMs: number; + evidenceProfile?: string; + candidate: DeployCandidate | null; + issueLinkage: IssueLinkageMode; +}; + +export type DeployReceipt = { + id: string; + timestamp: string; + project: string; + issueId?: number; + invocation: DeployRequest["invocation"]; + action: DeploymentAction; + issueLinkage: IssueLinkageMode; + sourceLane: string | null; + targetLane: string; + transitionKey: string; + candidate: DeployCandidate | null; + commandId: string; + command: string; + cwd?: string; + dryRun: boolean; + exitCode: number; + stdout: string; + stderr: string; + success: boolean; + evidenceProfile?: string; + evidence: string[]; + receiptPath?: string; + linkedIssueCommentId?: number | null; + configSnapshot?: Pick; +}; + +export type DeployEngineResult = { + decision: DeployDecision; + receipt: DeployReceipt; +}; diff --git a/lib/deployer/workflow.ts b/lib/deployer/workflow.ts new file mode 100644 index 00000000..3f019b0d --- /dev/null +++ b/lib/deployer/workflow.ts @@ -0,0 +1,43 @@ +import type { IssueProvider } from "../providers/provider.js"; +import type { Project } from "../projects/index.js"; +import type { DeploymentConfig } from "../config/types.js"; +import type { RunCommand } from "../context.js"; +import { runDeployEngine } from "./engine.js"; +import { renderDeployReceiptSummary } from "./receipt.js"; + +export async function runWorkflowDeployment(opts: { + workspaceDir: string; + project: Project; + repoPath: string; + provider: IssueProvider; + issueId: number; + currentStateKey: string; + config: DeploymentConfig; + runCommand: RunCommand; +}): Promise>> { + const mapping = opts.config.workflow?.states?.[opts.currentStateKey]; + if (!mapping) { + throw new Error(`No deployment.workflow.states entry configured for ${opts.currentStateKey}`); + } + + const result = await runDeployEngine({ + workspaceDir: opts.workspaceDir, + project: opts.project, + repoPath: opts.repoPath, + config: opts.config, + provider: opts.provider, + runCommand: opts.runCommand, + request: { + action: mapping.action, + sourceLane: mapping.sourceLane, + targetLane: mapping.targetLane, + issueId: opts.issueId, + issueLinkage: mapping.issueLinkage ?? "workflow", + invocation: { kind: "workflow", stateKey: opts.currentStateKey }, + }, + }); + + const commentId = await opts.provider.addComment(opts.issueId, renderDeployReceiptSummary(result.receipt)); + result.receipt.linkedIssueCommentId = commentId; + return result; +} diff --git a/lib/tools/deployer/deploy-run.ts b/lib/tools/deployer/deploy-run.ts new file mode 100644 index 00000000..cfafbf19 --- /dev/null +++ b/lib/tools/deployer/deploy-run.ts @@ -0,0 +1,88 @@ +import type { ToolContext } from "../../types.js"; +import type { PluginContext } from "../../context.js"; +import { jsonResult, requireWorkspaceDir, resolveChannelId, resolveProject, resolveProvider } from "../helpers.js"; +import { resolveRepoPath } from "../../projects/index.js"; +import { loadConfig } from "../../config/index.js"; +import { runDeployEngine } from "../../deployer/engine.js"; +import { renderDeployReceiptSummary } from "../../deployer/receipt.js"; + +export function createDeployRunTool(ctx: PluginContext) { + return (_toolCtx: ToolContext) => ({ + name: "deploy_run", + label: "Deploy Run", + description: "Run a direct deploy, promote, accept, or rollback operation using the shared deployer engine.", + parameters: { + type: "object", + required: ["channelId", "action", "targetLane"], + properties: { + channelId: { type: "string" }, + messageThreadId: { type: "number" }, + action: { type: "string", enum: ["deploy", "promote", "accept", "rollback"] }, + targetLane: { type: "string" }, + sourceLane: { type: "string" }, + candidateRef: { type: "string" }, + issueId: { type: "number" }, + issueLinkage: { type: "string", enum: ["none", "comment", "workflow"] }, + dryRun: { type: "boolean" }, + }, + }, + async execute(_id: string, params: Record) { + const workspaceDir = requireWorkspaceDir(_toolCtx); + const channelId = resolveChannelId(_toolCtx, params.channelId as string | undefined); + const messageThreadId = params.messageThreadId as number | undefined; + const channelType = (_toolCtx.messageChannel as string | undefined) ?? "telegram"; + const accountId = _toolCtx.agentAccountId as string | undefined; + const { project } = await resolveProject(workspaceDir, channelId, { channel: channelType, accountId, messageThreadId }); + const { provider } = await resolveProvider(project, ctx.runCommand); + const config = await loadConfig(workspaceDir, project.name); + const repoPath = resolveRepoPath(project.repo); + + const issueLinkage = (params.issueLinkage as "none" | "comment" | "workflow" | undefined) + ?? (params.issueId ? "comment" : "none"); + if ((issueLinkage === "comment" || issueLinkage === "workflow") && !params.issueId) { + throw new Error(`issueLinkage=${issueLinkage} requires issueId`); + } + + const result = await runDeployEngine({ + workspaceDir, + project, + repoPath, + config: config.deployment, + provider, + runCommand: ctx.runCommand, + request: { + action: params.action as "deploy" | "promote" | "accept" | "rollback", + targetLane: params.targetLane as string, + sourceLane: params.sourceLane as string | undefined, + candidateRef: params.candidateRef as string | undefined, + issueId: params.issueId as number | undefined, + dryRun: (params.dryRun as boolean) ?? false, + issueLinkage, + invocation: { kind: "direct" }, + }, + }); + + let linkedIssueCommentId: number | null = null; + if (params.issueId && issueLinkage !== "none") { + linkedIssueCommentId = await provider.addComment(params.issueId as number, renderDeployReceiptSummary(result.receipt)); + result.receipt.linkedIssueCommentId = linkedIssueCommentId; + } + + return jsonResult({ + success: result.receipt.success, + action: result.receipt.action, + transition: { + sourceLane: result.receipt.sourceLane, + targetLane: result.receipt.targetLane, + transitionKey: result.receipt.transitionKey, + }, + candidate: result.receipt.candidate, + receiptId: result.receipt.id, + receiptPath: result.receipt.receiptPath, + issueLinkage: result.receipt.issueLinkage, + linkedIssueCommentId, + dryRun: result.receipt.dryRun, + }); + }, + }); +} diff --git a/lib/tools/worker/work-finish.test.ts b/lib/tools/worker/work-finish.test.ts index 318010f3..17692796 100644 --- a/lib/tools/worker/work-finish.test.ts +++ b/lib/tools/worker/work-finish.test.ts @@ -11,10 +11,9 @@ */ import { describe, it, before, after } from "node:test"; import assert from "node:assert"; -import { mkdtemp, writeFile, readFile } from "node:fs/promises"; +import { mkdtemp, writeFile, readFile, rm } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; -import { rmdir } from "node:fs/promises"; // Helper to create a mock audit log with a merge_conflict transition async function createMockAuditLog(workspaceDir: string, issueId: number, hasMergeConflict: boolean): Promise { @@ -74,7 +73,7 @@ describe("work_finish: PR validation and conflict resolution", () => { after(async () => { // Clean up try { - await rmdir(tempDir, { recursive: true }); + await rm(tempDir, { recursive: true, force: true }); } catch { // ignore } @@ -243,12 +242,12 @@ describe("work_finish: PR validation and conflict resolution", () => { it("should handle non-Error exceptions gracefully", () => { // Test that non-Error objects don't cause issues - const notAnError = "some string"; + const notAnError: unknown = "some string"; const shouldRethrow = notAnError instanceof Error && - ((notAnError as any).message?.startsWith("Cannot mark work_finish(done)") || - (notAnError as any).message?.startsWith("Cannot complete work_finish(done)")); + (notAnError.message.startsWith("Cannot mark work_finish(done)") || + notAnError.message.startsWith("Cannot complete work_finish(done)")); assert.ok(!shouldRethrow, "Should not re-throw non-Error objects"); }); diff --git a/lib/tools/worker/work-finish.ts b/lib/tools/worker/work-finish.ts index d4abc3a6..8030a921 100644 --- a/lib/tools/worker/work-finish.ts +++ b/lib/tools/worker/work-finish.ts @@ -18,7 +18,9 @@ import { log as auditLog } from "../../audit.js"; import { DATA_DIR } from "../../setup/migrate-layout.js"; import { requireWorkspaceDir, resolveChannelId, resolveProject, resolveProvider } from "../helpers.js"; import { getAllRoleIds, isValidResult, getCompletionResults } from "../../roles/index.js"; -import { getCurrentStateLabel, loadWorkflow } from "../../workflow/index.js"; +import { findStateKeyByLabel, getCurrentStateLabel, loadWorkflow } from "../../workflow/index.js"; +import { loadConfig } from "../../config/index.js"; +import { runWorkflowDeployment } from "../../deployer/workflow.js"; /** * Get the current git branch name. @@ -261,6 +263,7 @@ export function createWorkFinishTool(ctx: PluginContext) { const { provider } = await resolveProvider(project, ctx.runCommand); const workflow = await loadWorkflow(workspaceDir, project.name); + const resolvedConfig = await loadConfig(workspaceDir, project.name); const issue = await provider.getIssue(issueId); const currentLabel = getCurrentStateLabel(issue.labels, workflow); @@ -270,6 +273,25 @@ export function createWorkFinishTool(ctx: PluginContext) { const repoPath = resolveRepoPath(project.repo); const pluginConfig = ctx.pluginConfig; + if (role === "deployer" && result === "done" && currentLabel) { + const stateKey = findStateKeyByLabel(workflow, currentLabel); + if (stateKey && resolvedConfig.deployment.workflow?.states?.[stateKey]) { + const deployResult = await runWorkflowDeployment({ + workspaceDir, + project, + repoPath, + provider, + issueId, + currentStateKey: stateKey, + config: resolvedConfig.deployment, + runCommand: ctx.runCommand, + }); + if (!deployResult.receipt.success) { + throw new Error(`Deployment command failed for ${stateKey}: ${deployResult.receipt.stderr || deployResult.receipt.stdout || deployResult.receipt.exitCode}`); + } + } + } + // For developers marking work as done, validate that a PR exists if (role === "developer" && result === "done") { await validatePrExistsForDeveloper(issueId, repoPath, provider, ctx.runCommand, workspaceDir, project.slug); From 2643bbe43ddb362ba720a34ed489542a44d149a2 Mon Sep 17 00:00:00 2001 From: fujiwaranosai850 Date: Tue, 26 May 2026 21:00:11 +0000 Subject: [PATCH 22/30] fix: harden deployer receipts and rollback semantics --- defaults/devclaw/workflow.yaml | 6 ++-- docs/ARCHITECTURE.md | 9 +++++ docs/CONFIGURATION.md | 11 ++++++ docs/TOOLS.md | 8 +++-- lib/deployer/engine.test.ts | 58 +++++++++++++++++++++++++++++++- lib/deployer/engine.ts | 13 +++++-- lib/deployer/receipt.ts | 13 ++++++- lib/deployer/resolve.test.ts | 7 ++-- lib/deployer/resolve.ts | 15 +++++---- lib/deployer/types.ts | 2 ++ lib/deployer/workflow.ts | 9 +++-- lib/tools/deployer/deploy-run.ts | 15 +++++---- 12 files changed, 135 insertions(+), 31 deletions(-) diff --git a/defaults/devclaw/workflow.yaml b/defaults/devclaw/workflow.yaml index 4f9e3f50..d811b5e7 100644 --- a/defaults/devclaw/workflow.yaml +++ b/defaults/devclaw/workflow.yaml @@ -270,7 +270,7 @@ deployment: accept-on-staging: run: echo "Accept ${CANDIDATE_REF} on ${TARGET_LANE}" rollback-from-production: - run: echo "Rollback ${TARGET_LANE} to ${SOURCE_LANE} using ${CANDIDATE_REF}" + run: echo "Rollback ${CANDIDATE_REF} from ${SOURCE_LANE} to ${TARGET_LANE}" evidenceProfiles: promotion: required: [command, candidate] @@ -297,8 +297,8 @@ deployment: command: accept-on-staging evidence: acceptance - action: rollback - from: staging - to: production + from: production + to: staging command: rollback-from-production evidence: rollback workflow: diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c74d3352..c41a3d75 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -129,6 +129,15 @@ DevClaw ships with four built-in roles, defined in `lib/roles/registry.ts`. All Roles are extensible — add a new entry to `ROLE_REGISTRY` and corresponding workflow states to get a new role. The `workflow.yaml` config can also override levels, models, and emoji per role, or disable a role entirely (`tester: false`). +## Deployer runtime + +Delivery work now flows through one shared deployer engine with two thin entrypoints: + +- workflow-backed deployer states such as `Promoting` and `Accepting` +- the direct `deploy_run` tool for explicit operational deploy commands + +The `deployment:` config block is the semantic source of truth for lane aliases, legal transitions, candidate resolution, command selection, rollback policy, and evidence requirements. Both entrypoints emit the same durable receipt shape, and receipts are finalized after optional linked issue comments so on-disk audit data matches the issue-visible outcome. + ## System overview ```mermaid diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 5ce1add1..1f60c231 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -107,14 +107,21 @@ deployment: lanes: build: { aliases: [candidate] } staging: { aliases: [stage], rollbackTargets: [build] } + production: { aliases: [prod], rollbackTargets: [staging] } commands: promote-to-staging: run: echo "Promote ${CANDIDATE_REF} from ${SOURCE_LANE} to ${TARGET_LANE}" + rollback-production-to-staging: + run: echo "Rollback ${CANDIDATE_REF} from ${SOURCE_LANE} to ${TARGET_LANE}" transitions: - action: promote from: build to: staging command: promote-to-staging + - action: rollback + from: production + to: staging + command: rollback-production-to-staging workflow: states: promoting: @@ -123,8 +130,12 @@ deployment: targetLane: staging ``` +For every deploy action, `sourceLane` means the origin lane and `targetLane` means the destination lane. Rollback uses the same directionality, for example `production -> staging`. + Legacy project metadata like `deployBranch` and `deployUrl` still acts as a fallback default, but it is no longer the semantic source of truth. +Receipts are persisted after any linked issue comment is posted, so the durable JSON receipt and issue summary stay aligned. + For the operator-facing contract, see [`../dev/design/deployer-contract.md`](../dev/design/deployer-contract.md). ### Timeouts diff --git a/docs/TOOLS.md b/docs/TOOLS.md index ee82c276..0589be2a 100644 --- a/docs/TOOLS.md +++ b/docs/TOOLS.md @@ -343,13 +343,15 @@ Direct operational deploy entrypoint backed by the shared deployer engine. | Parameter | Type | Required | Description | |---|---|---|---| | `channelId` | string | Yes | Current chat/group ID | -| `action` | `promote` \| `accept` \| `rollback` | Yes | Deploy action | -| `targetLane` | string | Yes | Canonical lane or alias | -| `sourceLane` | string | No | Source lane or alias | +| `action` | `deploy` \| `promote` \| `accept` \| `rollback` | Yes | Deploy action | +| `targetLane` | string | Yes | Destination lane or alias | +| `sourceLane` | string | No | Origin lane or alias | | `candidateRef` | string | No | Explicit candidate identity | | `issueId` | number | No | Optional issue linkage for comment receipts | | `dryRun` | boolean | No | Resolve and receipt without executing the command | +`deploy_run` uses the same shared engine and receipt model as workflow-backed delivery. When issue linkage is enabled, the durable receipt is written only after the linked comment outcome is known. + **What it does atomically:** 1. Validates project not already registered diff --git a/lib/deployer/engine.test.ts b/lib/deployer/engine.test.ts index 7d8d3bea..969346b9 100644 --- a/lib/deployer/engine.test.ts +++ b/lib/deployer/engine.test.ts @@ -36,7 +36,11 @@ describe("deploy engine", () => { const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-deploy-engine-")); const provider = new TestProvider(); provider.seedIssue({ iid: 7, labels: ["Promoting"] }); - const runCommand = async () => ({ stdout: "ok\n", stderr: "", code: 0, signal: null, killed: false as const }); + let executedCommand: string[] | undefined; + const runCommand = async (argv: string[]) => { + executedCommand = argv; + return { stdout: "ok\n", stderr: "", code: 0, signal: null, killed: false as const }; + }; const project = { name: "demo", deployBranch: "main", deployUrl: "", repo: "/tmp/repo" } as any; const direct = await runDeployEngine({ @@ -74,5 +78,57 @@ describe("deploy engine", () => { assert.equal(direct.receipt.candidate?.ref, "sha123"); assert.equal(workflow.receipt.issueLinkage, "workflow"); assert.ok(workflow.receipt.linkedIssueCommentId); + assert.ok(direct.receipt.receiptPath); + assert.equal(direct.receipt.linkedIssueCommentId, undefined); + assert.match(executedCommand?.[2] ?? "", /'sha123'/); + }); + + it("persists linked issue comment ids into the durable receipt", async () => { + const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-deploy-receipt-")); + const provider = new TestProvider(); + provider.seedIssue({ iid: 9, labels: ["Promoting"] }); + const project = { name: "demo", deployBranch: "main", deployUrl: "", repo: "/tmp/repo" } as any; + + const result = await runWorkflowDeployment({ + workspaceDir, + project, + repoPath: "/tmp/repo", + provider, + issueId: 9, + currentStateKey: "promoting", + config: deployment, + runCommand: (async () => ({ stdout: "ok\n", stderr: "", code: 0, signal: null, killed: false as const })) as any, + }); + + const persisted = JSON.parse(await fs.readFile(result.receipt.receiptPath!, "utf-8")); + assert.equal(persisted.linkedIssueCommentId, result.receipt.linkedIssueCommentId); + assert.ok(persisted.linkedIssueCommentId); + }); + + it("shell-escapes interpolated candidate values before execution", async () => { + const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-deploy-escape-")); + let executed: string[] | undefined; + const project = { name: "demo", deployBranch: "main", deployUrl: "", repo: "/tmp/repo" } as any; + + await runDeployEngine({ + workspaceDir, + project, + repoPath: "/tmp/repo", + config: deployment, + runCommand: (async (argv: string[]) => { + executed = argv; + return { stdout: "ok\n", stderr: "", code: 0, signal: null, killed: false as const }; + }) as any, + request: { + action: "promote", + sourceLane: "build", + targetLane: "staging", + candidateRef: "bad'; touch /tmp/pwned; echo '", + invocation: { kind: "direct" }, + }, + }); + + assert.ok(executed); + assert.match(executed?.[2] ?? "", /'bad'"'"'; touch \/tmp\/pwned; echo '"'"''/); }); }); diff --git a/lib/deployer/engine.ts b/lib/deployer/engine.ts index 04b51d51..a2162622 100644 --- a/lib/deployer/engine.ts +++ b/lib/deployer/engine.ts @@ -4,10 +4,14 @@ import type { DeploymentConfig } from "../config/types.js"; import type { IssueProvider } from "../providers/provider.js"; import { writeDeployReceipt } from "./receipt.js"; import { resolveDeployDecision } from "./resolve.js"; -import type { DeployEngineResult, DeployReceipt, DeployRequest } from "./types.js"; +import type { DeployEngineResult, DeployReceipt, DeployReceiptFinalizer, DeployRequest } from "./types.js"; + +function shellEscape(value: string): string { + return `'${value.replace(/'/g, `'"'"'`)}'`; +} function interpolate(template: string, vars: Record): string { - return template.replace(/\$\{([A-Z_]+)\}/g, (_m, key) => vars[key] ?? ""); + return template.replace(/\$\{([A-Z_]+)\}/g, (_m, key) => shellEscape(vars[key] ?? "")); } export async function runDeployEngine(opts: { @@ -18,6 +22,7 @@ export async function runDeployEngine(opts: { request: DeployRequest; runCommand: RunCommand; provider?: IssueProvider; + finalizeReceipt?: DeployReceiptFinalizer; }): Promise { const decision = await resolveDeployDecision({ request: opts.request, @@ -77,6 +82,10 @@ export async function runDeployEngine(opts: { configSnapshot: { policy: opts.config.policy }, }; + if (opts.finalizeReceipt) { + await opts.finalizeReceipt(receipt); + } + receipt.receiptPath = await writeDeployReceipt(opts.workspaceDir, opts.project.name, receipt); return { decision, receipt }; } diff --git a/lib/deployer/receipt.ts b/lib/deployer/receipt.ts index a5b3f616..955dc3d2 100644 --- a/lib/deployer/receipt.ts +++ b/lib/deployer/receipt.ts @@ -4,10 +4,14 @@ import { DATA_DIR } from "../setup/migrate-layout.js"; import { log as auditLog } from "../audit.js"; import type { DeployReceipt } from "./types.js"; +function getDeployReceiptPath(workspaceDir: string, projectName: string, receipt: DeployReceipt): string { + return join(workspaceDir, DATA_DIR, "deploy", projectName, `${receipt.timestamp.replace(/[:.]/g, "-")}-${receipt.id}.json`); +} + export async function writeDeployReceipt(workspaceDir: string, projectName: string, receipt: DeployReceipt): Promise { const dir = join(workspaceDir, DATA_DIR, "deploy", projectName); await mkdir(dir, { recursive: true }); - const filePath = join(dir, `${receipt.timestamp.replace(/[:.]/g, "-")}-${receipt.id}.json`); + const filePath = getDeployReceiptPath(workspaceDir, projectName, receipt); await writeFile(filePath, JSON.stringify(receipt, null, 2) + "\n", "utf-8"); await auditLog(workspaceDir, "deploy_receipt", { project: projectName, @@ -19,10 +23,17 @@ export async function writeDeployReceipt(workspaceDir: string, projectName: stri success: receipt.success, receiptPath: filePath, invocation: receipt.invocation.kind, + linkedIssueCommentId: receipt.linkedIssueCommentId ?? null, }); return filePath; } +export async function updateDeployReceipt(workspaceDir: string, projectName: string, receipt: DeployReceipt): Promise { + const filePath = receipt.receiptPath ?? getDeployReceiptPath(workspaceDir, projectName, receipt); + await writeFile(filePath, JSON.stringify(receipt, null, 2) + "\n", "utf-8"); + return filePath; +} + export function renderDeployReceiptSummary(receipt: DeployReceipt): string { const lines = [ "## DevClaw Deploy Receipt", diff --git a/lib/deployer/resolve.test.ts b/lib/deployer/resolve.test.ts index da77eb6c..08859d1d 100644 --- a/lib/deployer/resolve.test.ts +++ b/lib/deployer/resolve.test.ts @@ -19,9 +19,10 @@ describe("deployer resolve helpers", () => { assert.equal(resolveLaneAlias(config, "missing"), null); }); - it("validates rollback legality", () => { - assert.doesNotThrow(() => validateRollbackLegality(config, "build", "staging")); - assert.throws(() => validateRollbackLegality(config, "build", "production"), /not allowed/); + it("validates rollback legality with source as the rolled back lane and target as the destination lane", () => { + assert.doesNotThrow(() => validateRollbackLegality(config, "staging", "build")); + assert.throws(() => validateRollbackLegality(config, "production", "build"), /not allowed/); + assert.throws(() => validateRollbackLegality(config, null, "build"), /requires sourceLane/); }); it("backfills legacy deployment defaults", () => { diff --git a/lib/deployer/resolve.ts b/lib/deployer/resolve.ts index a13d3923..68f8f735 100644 --- a/lib/deployer/resolve.ts +++ b/lib/deployer/resolve.ts @@ -55,10 +55,12 @@ export function selectTransition(config: DeploymentConfig, action: DeploymentAct } export function validateRollbackLegality(config: DeploymentConfig, sourceLane: string | null, targetLane: string): void { - if (!sourceLane) return; - const allowed = config.lanes?.[targetLane]?.rollbackTargets ?? []; - if (allowed.length > 0 && !allowed.includes(sourceLane)) { - throw new Error(`Rollback from ${targetLane} to ${sourceLane} is not allowed by deployment config`); + if (!sourceLane) { + throw new Error("Rollback requires sourceLane to identify the lane being rolled back from"); + } + const allowed = config.lanes?.[sourceLane]?.rollbackTargets ?? []; + if (allowed.length > 0 && !allowed.includes(targetLane)) { + throw new Error(`Rollback from ${sourceLane} to ${targetLane} is not allowed by deployment config`); } } @@ -122,10 +124,11 @@ export async function resolveDeployDecision(opts: { if (opts.request.action === "rollback") validateRollbackLegality(config, sourceLane, targetLane); - const laneCfg = config.lanes?.[targetLane]; + const policyLane = opts.request.action === "rollback" ? sourceLane ?? targetLane : targetLane; + const laneCfg = policyLane ? config.lanes?.[policyLane] : undefined; if (laneCfg?.humanOnly || (laneCfg?.protected && config.policy?.requireHumanForProtectedLanes)) { if (opts.request.invocation.kind === "direct") { - throw new Error(`Lane ${targetLane} is human-only for direct deploys`); + throw new Error(`Lane ${policyLane} is human-only for direct deploys`); } } diff --git a/lib/deployer/types.ts b/lib/deployer/types.ts index a9f11104..090f4b1e 100644 --- a/lib/deployer/types.ts +++ b/lib/deployer/types.ts @@ -64,6 +64,8 @@ export type DeployReceipt = { configSnapshot?: Pick; }; +export type DeployReceiptFinalizer = (receipt: DeployReceipt) => Promise; + export type DeployEngineResult = { decision: DeployDecision; receipt: DeployReceipt; diff --git a/lib/deployer/workflow.ts b/lib/deployer/workflow.ts index 3f019b0d..6acd56e1 100644 --- a/lib/deployer/workflow.ts +++ b/lib/deployer/workflow.ts @@ -20,7 +20,7 @@ export async function runWorkflowDeployment(opts: { throw new Error(`No deployment.workflow.states entry configured for ${opts.currentStateKey}`); } - const result = await runDeployEngine({ + return runDeployEngine({ workspaceDir: opts.workspaceDir, project: opts.project, repoPath: opts.repoPath, @@ -35,9 +35,8 @@ export async function runWorkflowDeployment(opts: { issueLinkage: mapping.issueLinkage ?? "workflow", invocation: { kind: "workflow", stateKey: opts.currentStateKey }, }, + finalizeReceipt: async (receipt) => { + receipt.linkedIssueCommentId = await opts.provider.addComment(opts.issueId, renderDeployReceiptSummary(receipt)); + }, }); - - const commentId = await opts.provider.addComment(opts.issueId, renderDeployReceiptSummary(result.receipt)); - result.receipt.linkedIssueCommentId = commentId; - return result; } diff --git a/lib/tools/deployer/deploy-run.ts b/lib/tools/deployer/deploy-run.ts index cfafbf19..2b5e5b11 100644 --- a/lib/tools/deployer/deploy-run.ts +++ b/lib/tools/deployer/deploy-run.ts @@ -18,8 +18,8 @@ export function createDeployRunTool(ctx: PluginContext) { channelId: { type: "string" }, messageThreadId: { type: "number" }, action: { type: "string", enum: ["deploy", "promote", "accept", "rollback"] }, - targetLane: { type: "string" }, - sourceLane: { type: "string" }, + targetLane: { type: "string", description: "Destination lane or alias" }, + sourceLane: { type: "string", description: "Origin lane or alias" }, candidateRef: { type: "string" }, issueId: { type: "number" }, issueLinkage: { type: "string", enum: ["none", "comment", "workflow"] }, @@ -60,13 +60,14 @@ export function createDeployRunTool(ctx: PluginContext) { issueLinkage, invocation: { kind: "direct" }, }, + finalizeReceipt: async (receipt) => { + if (params.issueId && issueLinkage !== "none") { + receipt.linkedIssueCommentId = await provider.addComment(params.issueId as number, renderDeployReceiptSummary(receipt)); + } + }, }); - let linkedIssueCommentId: number | null = null; - if (params.issueId && issueLinkage !== "none") { - linkedIssueCommentId = await provider.addComment(params.issueId as number, renderDeployReceiptSummary(result.receipt)); - result.receipt.linkedIssueCommentId = linkedIssueCommentId; - } + const linkedIssueCommentId = result.receipt.linkedIssueCommentId ?? null; return jsonResult({ success: result.receipt.success, From 91233dcc904acc4538be71ddfbf734eae1f2e768 Mon Sep 17 00:00:00 2001 From: fujiwaranosai850 Date: Tue, 26 May 2026 21:14:42 +0000 Subject: [PATCH 23/30] fix: execute deploy commands with env-bound inputs --- docs/ARCHITECTURE.md | 2 +- docs/CONFIGURATION.md | 2 ++ lib/deployer/engine.test.ts | 16 +++++++++++----- lib/deployer/engine.ts | 12 +++++------- 4 files changed, 19 insertions(+), 13 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c41a3d75..35fe405c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -136,7 +136,7 @@ Delivery work now flows through one shared deployer engine with two thin entrypo - workflow-backed deployer states such as `Promoting` and `Accepting` - the direct `deploy_run` tool for explicit operational deploy commands -The `deployment:` config block is the semantic source of truth for lane aliases, legal transitions, candidate resolution, command selection, rollback policy, and evidence requirements. Both entrypoints emit the same durable receipt shape, and receipts are finalized after optional linked issue comments so on-disk audit data matches the issue-visible outcome. +The `deployment:` config block is the semantic source of truth for lane aliases, legal transitions, candidate resolution, command selection, rollback policy, and evidence requirements. Both entrypoints emit the same durable receipt shape, receipts are finalized after optional linked issue comments so on-disk audit data matches the issue-visible outcome, and command templates run with deploy values injected through env rather than shell-string interpolation. ## System overview diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 1f60c231..1ac35bfd 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -132,6 +132,8 @@ deployment: For every deploy action, `sourceLane` means the origin lane and `targetLane` means the destination lane. Rollback uses the same directionality, for example `production -> staging`. +Deploy commands are executed as fixed shell programs with dynamic values passed through environment variables like `CANDIDATE_REF`, `SOURCE_LANE`, and `TARGET_LANE`. That keeps configured commands ergonomic while avoiding direct string interpolation of tool input into the shell program text. + Legacy project metadata like `deployBranch` and `deployUrl` still acts as a fallback default, but it is no longer the semantic source of truth. Receipts are persisted after any linked issue comment is posted, so the durable JSON receipt and issue summary stay aligned. diff --git a/lib/deployer/engine.test.ts b/lib/deployer/engine.test.ts index 969346b9..7c076d7c 100644 --- a/lib/deployer/engine.test.ts +++ b/lib/deployer/engine.test.ts @@ -37,8 +37,10 @@ describe("deploy engine", () => { const provider = new TestProvider(); provider.seedIssue({ iid: 7, labels: ["Promoting"] }); let executedCommand: string[] | undefined; - const runCommand = async (argv: string[]) => { + let executedEnv: Record | undefined; + const runCommand = async (argv: string[], opts?: { env?: Record }) => { executedCommand = argv; + executedEnv = opts?.env; return { stdout: "ok\n", stderr: "", code: 0, signal: null, killed: false as const }; }; const project = { name: "demo", deployBranch: "main", deployUrl: "", repo: "/tmp/repo" } as any; @@ -80,7 +82,8 @@ describe("deploy engine", () => { assert.ok(workflow.receipt.linkedIssueCommentId); assert.ok(direct.receipt.receiptPath); assert.equal(direct.receipt.linkedIssueCommentId, undefined); - assert.match(executedCommand?.[2] ?? "", /'sha123'/); + assert.equal(executedCommand?.[2], 'echo "promote ${CANDIDATE_REF} ${SOURCE_LANE} ${TARGET_LANE}"'); + assert.equal(executedEnv?.CANDIDATE_REF, "sha123"); }); it("persists linked issue comment ids into the durable receipt", async () => { @@ -105,9 +108,10 @@ describe("deploy engine", () => { assert.ok(persisted.linkedIssueCommentId); }); - it("shell-escapes interpolated candidate values before execution", async () => { + it("passes dynamic deploy values via env instead of interpolating them into the shell program", async () => { const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-deploy-escape-")); let executed: string[] | undefined; + let executedEnv: Record | undefined; const project = { name: "demo", deployBranch: "main", deployUrl: "", repo: "/tmp/repo" } as any; await runDeployEngine({ @@ -115,8 +119,9 @@ describe("deploy engine", () => { project, repoPath: "/tmp/repo", config: deployment, - runCommand: (async (argv: string[]) => { + runCommand: (async (argv: string[], opts?: { env?: Record }) => { executed = argv; + executedEnv = opts?.env; return { stdout: "ok\n", stderr: "", code: 0, signal: null, killed: false as const }; }) as any, request: { @@ -129,6 +134,7 @@ describe("deploy engine", () => { }); assert.ok(executed); - assert.match(executed?.[2] ?? "", /'bad'"'"'; touch \/tmp\/pwned; echo '"'"''/); + assert.equal(executed?.[2], 'echo "promote ${CANDIDATE_REF} ${SOURCE_LANE} ${TARGET_LANE}"'); + assert.equal(executedEnv?.CANDIDATE_REF, "bad'; touch /tmp/pwned; echo '"); }); }); diff --git a/lib/deployer/engine.ts b/lib/deployer/engine.ts index a2162622..483ffa8c 100644 --- a/lib/deployer/engine.ts +++ b/lib/deployer/engine.ts @@ -6,12 +6,8 @@ import { writeDeployReceipt } from "./receipt.js"; import { resolveDeployDecision } from "./resolve.js"; import type { DeployEngineResult, DeployReceipt, DeployReceiptFinalizer, DeployRequest } from "./types.js"; -function shellEscape(value: string): string { - return `'${value.replace(/'/g, `'"'"'`)}'`; -} - -function interpolate(template: string, vars: Record): string { - return template.replace(/\$\{([A-Z_]+)\}/g, (_m, key) => shellEscape(vars[key] ?? "")); +function buildDeployEnv(vars: Record): NodeJS.ProcessEnv { + return Object.fromEntries(Object.entries(vars).map(([key, value]) => [key, value])); } export async function runDeployEngine(opts: { @@ -42,7 +38,8 @@ export async function runDeployEngine(opts: { PROJECT: opts.project.name, }; - const command = interpolate(decision.command, vars); + const command = decision.command; + const commandEnv = buildDeployEnv(vars); let stdout = ""; let stderr = ""; let exitCode = 0; @@ -51,6 +48,7 @@ export async function runDeployEngine(opts: { const result = await opts.runCommand(["bash", "-lc", command], { cwd: decision.cwd ?? opts.repoPath, timeoutMs: decision.timeoutMs, + env: commandEnv, }); stdout = result.stdout ?? ""; stderr = result.stderr ?? ""; From 25667d4cd646f2ea562ddec58572f8966dd97aa2 Mon Sep 17 00:00:00 2001 From: fujiwaranosai850 Date: Tue, 26 May 2026 21:27:47 +0000 Subject: [PATCH 24/30] fix: deep-merge deployment config overlays --- lib/config/merge.test.ts | 68 ++++++++++++++++++++++++++++++++++++ lib/config/merge.ts | 74 ++++++++++++++++++++++++---------------- 2 files changed, 112 insertions(+), 30 deletions(-) create mode 100644 lib/config/merge.test.ts diff --git a/lib/config/merge.test.ts b/lib/config/merge.test.ts new file mode 100644 index 00000000..439e0c22 --- /dev/null +++ b/lib/config/merge.test.ts @@ -0,0 +1,68 @@ +import { describe, it } from "node:test"; +import assert from "node:assert"; +import { mergeConfig } from "./merge.js"; + +describe("mergeConfig deployment merging", () => { + it("deep-merges individual deployment lane objects", () => { + const merged = mergeConfig({ + deployment: { + lanes: { + production: { + aliases: ["prod"], + protected: true, + rollbackTargets: ["staging"], + }, + }, + }, + }, { + deployment: { + lanes: { + production: { + humanOnly: true, + }, + }, + }, + }); + + assert.deepStrictEqual(merged.deployment?.lanes?.production, { + aliases: ["prod"], + protected: true, + rollbackTargets: ["staging"], + humanOnly: true, + }); + }); + + it("deep-merges deployment workflow state objects", () => { + const merged = mergeConfig({ + deployment: { + workflow: { + states: { + promoting: { + action: "promote", + sourceLane: "build", + targetLane: "staging", + issueLinkage: "workflow", + }, + }, + }, + }, + }, { + deployment: { + workflow: { + states: { + promoting: { + issueLinkage: "comment", + } as any, + }, + }, + }, + }); + + assert.deepStrictEqual(merged.deployment?.workflow?.states?.promoting, { + action: "promote", + sourceLane: "build", + targetLane: "staging", + issueLinkage: "comment", + }); + }); +}); diff --git a/lib/config/merge.ts b/lib/config/merge.ts index c0f2cf6f..b64f2b3d 100644 --- a/lib/config/merge.ts +++ b/lib/config/merge.ts @@ -7,7 +7,7 @@ * - `false` for a role: marks it as disabled * - Primitives: override */ -import type { DevClawConfig, RoleOverride } from "./types.js"; +import type { DeploymentConfig, DevClawConfig, RoleOverride } from "./types.js"; /** * Merge a config overlay on top of a base config. @@ -74,35 +74,7 @@ export function mergeConfig( } if (base.deployment || overlay.deployment) { - merged.deployment = { - ...base.deployment, - ...overlay.deployment, - lanes: base.deployment?.lanes || overlay.deployment?.lanes - ? { ...base.deployment?.lanes, ...overlay.deployment?.lanes } - : undefined, - commands: base.deployment?.commands || overlay.deployment?.commands - ? { ...base.deployment?.commands, ...overlay.deployment?.commands } - : undefined, - evidenceProfiles: base.deployment?.evidenceProfiles || overlay.deployment?.evidenceProfiles - ? { ...base.deployment?.evidenceProfiles, ...overlay.deployment?.evidenceProfiles } - : undefined, - workflow: base.deployment?.workflow || overlay.deployment?.workflow - ? { - ...base.deployment?.workflow, - ...overlay.deployment?.workflow, - states: base.deployment?.workflow?.states || overlay.deployment?.workflow?.states - ? { ...base.deployment?.workflow?.states, ...overlay.deployment?.workflow?.states } - : undefined, - } - : undefined, - candidate: base.deployment?.candidate || overlay.deployment?.candidate - ? { ...base.deployment?.candidate, ...overlay.deployment?.candidate } - : undefined, - policy: base.deployment?.policy || overlay.deployment?.policy - ? { ...base.deployment?.policy, ...overlay.deployment?.policy } - : undefined, - transitions: overlay.deployment?.transitions ?? base.deployment?.transitions, - }; + merged.deployment = mergeDeploymentConfig(base.deployment, overlay.deployment); } // Merge timeouts @@ -133,3 +105,45 @@ function mergeRoleOverride( ...(overlay.completionResults ? { completionResults: overlay.completionResults } : {}), }; } + +function mergeDeploymentConfig( + base?: DeploymentConfig, + overlay?: DeploymentConfig, +): DeploymentConfig { + return { + ...base, + ...overlay, + lanes: mergeRecordObjects(base?.lanes, overlay?.lanes), + commands: mergeRecordObjects(base?.commands, overlay?.commands), + evidenceProfiles: mergeRecordObjects(base?.evidenceProfiles, overlay?.evidenceProfiles), + workflow: base?.workflow || overlay?.workflow + ? { + ...base?.workflow, + ...overlay?.workflow, + states: mergeRecordObjects(base?.workflow?.states, overlay?.workflow?.states), + } + : undefined, + candidate: base?.candidate || overlay?.candidate + ? { ...base?.candidate, ...overlay?.candidate } + : undefined, + policy: base?.policy || overlay?.policy + ? { ...base?.policy, ...overlay?.policy } + : undefined, + transitions: overlay?.transitions ?? base?.transitions, + }; +} + +function mergeRecordObjects( + base?: Record, + overlay?: Record, +): Record | undefined { + if (!base && !overlay) return undefined; + + const merged: Record = { ...(base ?? {}) }; + for (const [key, value] of Object.entries(overlay ?? {})) { + merged[key] = key in merged + ? { ...merged[key], ...value } + : value; + } + return merged; +} From d06239efd43585563f8b492566a0b5689c19bb7c Mon Sep 17 00:00:00 2001 From: fujiwaranosai850 Date: Tue, 26 May 2026 21:49:36 +0000 Subject: [PATCH 25/30] fix: seed workflow deploy candidate into shared engine --- lib/deployer/engine.test.ts | 16 +++++++++++++++- lib/deployer/workflow.ts | 5 +++++ lib/tools/worker/work-finish.test.ts | 17 ++++++----------- 3 files changed, 26 insertions(+), 12 deletions(-) diff --git a/lib/deployer/engine.test.ts b/lib/deployer/engine.test.ts index 7c076d7c..dc5b062c 100644 --- a/lib/deployer/engine.test.ts +++ b/lib/deployer/engine.test.ts @@ -7,6 +7,7 @@ import { runDeployEngine } from "./engine.js"; import { runWorkflowDeployment } from "./workflow.js"; import { TestProvider } from "../testing/test-provider.js"; import type { DeploymentConfig } from "../config/types.js"; +import { renderCandidateRecord } from "../workflow/candidate-provenance.js"; const deployment: DeploymentConfig = { lanes: { @@ -27,7 +28,7 @@ const deployment: DeploymentConfig = { promoting: { action: "promote", sourceLane: "build", targetLane: "staging", issueLinkage: "workflow" }, }, }, - candidate: { sources: ["explicit"] }, + candidate: { sources: ["explicit", "issueCandidate"] }, policy: { allowDirectWithoutIssue: true }, }; @@ -36,6 +37,12 @@ describe("deploy engine", () => { const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-deploy-engine-")); const provider = new TestProvider(); provider.seedIssue({ iid: 7, labels: ["Promoting"] }); + await provider.addComment(7, renderCandidateRecord({ + issueId: 7, + candidateId: "sha123", + commitSha: "sha123", + status: "active", + })); let executedCommand: string[] | undefined; let executedEnv: Record | undefined; const runCommand = async (argv: string[], opts?: { env?: Record }) => { @@ -78,6 +85,7 @@ describe("deploy engine", () => { assert.equal(direct.receipt.targetLane, workflow.receipt.targetLane); assert.equal(direct.receipt.transitionKey, workflow.receipt.transitionKey); assert.equal(direct.receipt.candidate?.ref, "sha123"); + assert.equal(workflow.receipt.candidate?.ref, "sha123"); assert.equal(workflow.receipt.issueLinkage, "workflow"); assert.ok(workflow.receipt.linkedIssueCommentId); assert.ok(direct.receipt.receiptPath); @@ -90,6 +98,12 @@ describe("deploy engine", () => { const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "devclaw-deploy-receipt-")); const provider = new TestProvider(); provider.seedIssue({ iid: 9, labels: ["Promoting"] }); + await provider.addComment(9, renderCandidateRecord({ + issueId: 9, + candidateId: "sha123", + commitSha: "sha123", + status: "active", + })); const project = { name: "demo", deployBranch: "main", deployUrl: "", repo: "/tmp/repo" } as any; const result = await runWorkflowDeployment({ diff --git a/lib/deployer/workflow.ts b/lib/deployer/workflow.ts index 6acd56e1..41c5e2b7 100644 --- a/lib/deployer/workflow.ts +++ b/lib/deployer/workflow.ts @@ -2,6 +2,7 @@ import type { IssueProvider } from "../providers/provider.js"; import type { Project } from "../projects/index.js"; import type { DeploymentConfig } from "../config/types.js"; import type { RunCommand } from "../context.js"; +import { getCurrentCandidate } from "../workflow/candidate-provenance.js"; import { runDeployEngine } from "./engine.js"; import { renderDeployReceiptSummary } from "./receipt.js"; @@ -20,6 +21,9 @@ export async function runWorkflowDeployment(opts: { throw new Error(`No deployment.workflow.states entry configured for ${opts.currentStateKey}`); } + const currentCandidate = await getCurrentCandidate(opts.provider, opts.issueId); + const candidateRef = currentCandidate?.candidateId ?? currentCandidate?.commitSha ?? undefined; + return runDeployEngine({ workspaceDir: opts.workspaceDir, project: opts.project, @@ -31,6 +35,7 @@ export async function runWorkflowDeployment(opts: { action: mapping.action, sourceLane: mapping.sourceLane, targetLane: mapping.targetLane, + candidateRef, issueId: opts.issueId, issueLinkage: mapping.issueLinkage ?? "workflow", invocation: { kind: "workflow", stateKey: opts.currentStateKey }, diff --git a/lib/tools/worker/work-finish.test.ts b/lib/tools/worker/work-finish.test.ts index 17692796..dc3bf190 100644 --- a/lib/tools/worker/work-finish.test.ts +++ b/lib/tools/worker/work-finish.test.ts @@ -11,22 +11,15 @@ */ import { describe, it, before, after } from "node:test"; import assert from "node:assert"; -import { mkdtemp, writeFile, readFile, rm } from "node:fs/promises"; +import { mkdtemp, writeFile, readFile, rm, mkdir } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; // Helper to create a mock audit log with a merge_conflict transition async function createMockAuditLog(workspaceDir: string, issueId: number, hasMergeConflict: boolean): Promise { const logDir = join(workspaceDir, "devclaw", "log"); - - // Ensure directory exists - try { - await writeFile(join(workspaceDir, "devclaw", "placeholder"), ""); - } catch { - // ignore - } - - const auditPath = join(workspaceDir, "devclaw", "log", "audit.log"); + await mkdir(logDir, { recursive: true }); + const auditPath = join(logDir, "audit.log"); const entries = []; // Add some dummy entries @@ -141,7 +134,9 @@ describe("work_finish: PR validation and conflict resolution", () => { }); it("should skip malformed JSON lines in audit log", async () => { - const auditPath = join(tempDir, "devclaw", "log", "audit.log"); + const logDir = join(tempDir, "devclaw", "log"); + await mkdir(logDir, { recursive: true }); + const auditPath = join(logDir, "audit.log"); const entries = [ JSON.stringify({ event: "valid", issueId: 999 }), "{ invalid json", From 8e12626a062cb6b824fd0ef4af3521950df3ae69 Mon Sep 17 00:00:00 2001 From: fujiwaranosai850 Date: Sun, 31 May 2026 04:26:47 +0000 Subject: [PATCH 26/30] fix: enforce canonical PR routing integrity --- lib/dispatch/index.ts | 5 +- lib/dispatch/message-builder.ts | 1 + lib/dispatch/pr-context.test.ts | 116 +++++++--- lib/dispatch/pr-context.ts | 102 +++++---- lib/providers/github.ts | 223 +++++++++++++------ lib/providers/gitlab.ts | 149 ++++++++++--- lib/providers/provider-pr-status.test.ts | 257 ++++++++++++++++++++- lib/providers/provider.ts | 18 +- lib/services/canonical-pr.test.ts | 135 +++++++++++ lib/services/canonical-pr.ts | 271 +++++++++++++++++++++++ lib/services/heartbeat/review.ts | 48 +++- lib/services/pipeline.e2e.test.ts | 54 +++++ lib/services/pipeline.ts | 25 ++- lib/testing/test-provider.ts | 53 ++++- lib/tools/worker/work-finish.test.ts | 31 +++ lib/tools/worker/work-finish.ts | 51 +++-- 16 files changed, 1325 insertions(+), 214 deletions(-) create mode 100644 lib/services/canonical-pr.test.ts create mode 100644 lib/services/canonical-pr.ts diff --git a/lib/dispatch/index.ts b/lib/dispatch/index.ts index eb2c2d86..b5d60186 100644 --- a/lib/dispatch/index.ts +++ b/lib/dispatch/index.ts @@ -150,7 +150,6 @@ export async function dispatchTask( ).catch(() => {}); existingSessionKey = null; } - const sessionAction = existingSessionKey ? "send" : "spawn"; // Fetch comments to include in task context @@ -158,9 +157,9 @@ export async function dispatchTask( // Fetch PR context based on workflow role semantics (no hardcoded role/label checks) const prFeedback = isFeedbackState(workflow, fromLabel) - ? await fetchPrFeedback(provider, issueId) : undefined; + ? await fetchPrFeedback(provider, issueId, { workspaceDir, projectSlug: project.slug }) : undefined; const prContext = hasReviewCheck(workflow, role) - ? await fetchPrContext(provider, issueId) : undefined; + ? await fetchPrContext(provider, issueId, { workspaceDir, projectSlug: project.slug }) : undefined; // Fetch attachment context (best-effort — never blocks dispatch) let attachmentContext: string | undefined; diff --git a/lib/dispatch/message-builder.ts b/lib/dispatch/message-builder.ts index 4ea06b54..de3b3247 100644 --- a/lib/dispatch/message-builder.ts +++ b/lib/dispatch/message-builder.ts @@ -53,6 +53,7 @@ export function buildTaskMessage(opts: { `> **⚠️ FEEDBACK CYCLE — This issue is returning from review.**`, `> The original description above is for context only.`, `> Your job is to address the PR Review Feedback and Comments below.`, + `> Reuse the existing canonical PR and branch unless the task explicitly says to replace them.`, `> When feedback conflicts with the original description, follow the feedback.`, ); } diff --git a/lib/dispatch/pr-context.test.ts b/lib/dispatch/pr-context.test.ts index fa9d24a3..b3315bde 100644 --- a/lib/dispatch/pr-context.test.ts +++ b/lib/dispatch/pr-context.test.ts @@ -1,8 +1,15 @@ -import { describe, it, expect } from "vitest"; -import { formatPrFeedback, type PrFeedback } from "./pr-context.js"; +import { describe, it } from "node:test"; +import assert from "node:assert"; +import { mkdtemp, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fetchPrContext, fetchPrFeedback, formatPrFeedback, type PrFeedback } from "./pr-context.js"; +import { TestProvider } from "../testing/test-provider.js"; +import { PrState } from "../providers/provider.js"; +import { recordCanonicalPr } from "../services/canonical-pr.js"; describe("formatPrFeedback", () => { - it("returns empty array when no comments", () => { + it("preserves canonical PR context even when no comments were retrieved", () => { const feedback: PrFeedback = { url: "https://github.com/user/repo/pull/123", branchName: "feature/123-test", @@ -10,7 +17,10 @@ describe("formatPrFeedback", () => { comments: [], }; const result = formatPrFeedback(feedback, "main"); - expect(result).toEqual([]); + const text = result.join("\n"); + assert.match(text, /https:\/\/github.com\/user\/repo\/pull\/123/); + assert.match(text, /No review comment bodies were retrieved/); + assert.match(text, /feature\/123-test/); }); it("includes branch name in conflict resolution instructions", () => { @@ -30,10 +40,10 @@ describe("formatPrFeedback", () => { const result = formatPrFeedback(feedback, "main"); const text = result.join("\n"); - expect(text).toContain("feature/456-test"); - expect(text).toContain("🔹 Branch: `feature/456-test`"); - expect(text).toContain("git checkout feature/456-test"); - expect(text).toContain("git push --force-with-lease origin feature/456-test"); + assert.match(text, /feature\/456-test/); + assert.match(text, /🔹 Branch: `feature\/456-test`/); + assert.match(text, /git checkout feature\/456-test/); + assert.match(text, /git push --force-with-lease origin feature\/456-test/); }); it("uses fallback branch name when not provided", () => { @@ -52,8 +62,8 @@ describe("formatPrFeedback", () => { const result = formatPrFeedback(feedback, "main"); const text = result.join("\n"); - expect(text).toContain("your-branch"); - expect(text).toContain("🔹 Branch: `your-branch`"); + assert.match(text, /your-branch/); + assert.match(text, /🔹 Branch: `your-branch`/); }); it("includes step-by-step instructions for conflict resolution", () => { @@ -73,17 +83,14 @@ describe("formatPrFeedback", () => { const result = formatPrFeedback(feedback, "develop"); const text = result.join("\n"); - // Check all steps are present - expect(text).toContain("1. Fetch and check out the PR branch"); - expect(text).toContain("2. Rebase onto `develop`"); - expect(text).toContain("3. Resolve any conflicts"); - expect(text).toContain("4. Force-push to the SAME branch"); - expect(text).toContain("5. Verify the PR shows as mergeable"); - - // Check warning about not creating new PR - expect(text).toContain("⚠️ Do NOT create a new PR"); - expect(text).toContain("Do NOT switch branches"); - expect(text).toContain("Update THIS PR only"); + assert.match(text, /1\. Fetch and check out the PR branch/); + assert.match(text, /2\. Rebase onto `develop`/); + assert.match(text, /3\. Resolve any conflicts/); + assert.match(text, /4\. Force-push to the SAME branch/); + assert.match(text, /5\. Verify the PR shows as mergeable/); + assert.match(text, /⚠️ Do NOT create a new PR/); + assert.match(text, /Do NOT switch branches/); + assert.match(text, /Update THIS canonical PR only/); }); it("correctly formats changes_requested feedback", () => { @@ -103,10 +110,9 @@ describe("formatPrFeedback", () => { const result = formatPrFeedback(feedback, "main"); const text = result.join("\n"); - expect(text).toContain("⚠️ Changes were requested"); - expect(text).toContain("Please make these changes"); - // Should NOT have conflict resolution instructions - expect(text).not.toContain("Conflict Resolution Instructions"); + assert.match(text, /⚠️ Changes were requested/); + assert.match(text, /Please make these changes/); + assert.doesNotMatch(text, /Conflict Resolution Instructions/); }); it("includes comment location information when available", () => { @@ -128,7 +134,7 @@ describe("formatPrFeedback", () => { const result = formatPrFeedback(feedback, "main"); const text = result.join("\n"); - expect(text).toContain("(src/index.ts:42)"); + assert.match(text, /\(src\/index\.ts:42\)/); }); it("uses correct base branch in rebase command", () => { @@ -146,14 +152,64 @@ describe("formatPrFeedback", () => { ], }; - // Test with "main" base branch let result = formatPrFeedback(feedback, "main"); let text = result.join("\n"); - expect(text).toContain("git rebase main"); + assert.match(text, /git rebase main/); - // Test with "develop" base branch result = formatPrFeedback(feedback, "develop"); text = result.join("\n"); - expect(text).toContain("git rebase develop"); + assert.match(text, /git rebase develop/); + }); +}); + +describe("canonical PR dispatch routing", () => { + it("fails closed when canonical status lookup no longer resolves", async () => { + const workspaceDir = await mkdtemp(path.join(os.tmpdir(), "devclaw-pr-context-")); + try { + const provider = new TestProvider(); + provider.seedIssue({ iid: 77, title: "Review me", labels: ["To Review"] }); + provider.setLinkedPrs(77, [{ number: 77, url: "https://example.com/pr/77", title: "Review me", sourceBranch: "issue/77-review-me" }]); + await recordCanonicalPr(workspaceDir, "test-project", 77, { + number: 77, + url: "https://example.com/pr/77", + title: "Review me", + sourceBranch: "issue/77-review-me", + }, PrState.OPEN); + + await assert.rejects( + () => fetchPrContext(provider, 77, { workspaceDir, projectSlug: "test-project" }), + /stored PR https:\/\/example\.com\/pr\/77 no longer resolves/, + ); + } finally { + await rm(workspaceDir, { recursive: true, force: true }); + } + }); + + it("preserves canonical feedback routing even when comments are empty", async () => { + const workspaceDir = await mkdtemp(path.join(os.tmpdir(), "devclaw-pr-feedback-")); + try { + const provider = new TestProvider(); + provider.seedIssue({ iid: 78, title: "Needs changes", labels: ["To Improve"] }); + provider.setPrStatus(78, { + state: PrState.CHANGES_REQUESTED, + url: "https://example.com/pr/78", + number: 78, + sourceBranch: "issue/78-needs-changes", + }); + await recordCanonicalPr(workspaceDir, "test-project", 78, { + number: 78, + url: "https://example.com/pr/78", + title: "Needs changes", + sourceBranch: "issue/78-needs-changes", + }, PrState.CHANGES_REQUESTED); + + const feedback = await fetchPrFeedback(provider, 78, { workspaceDir, projectSlug: "test-project" }); + assert.ok(feedback); + assert.strictEqual(feedback?.url, "https://example.com/pr/78"); + assert.strictEqual(feedback?.reason, "changes_requested"); + assert.deepStrictEqual(feedback?.comments, []); + } finally { + await rm(workspaceDir, { recursive: true, force: true }); + } }); }); diff --git a/lib/dispatch/pr-context.ts b/lib/dispatch/pr-context.ts index 9d2a5263..1bf4b449 100644 --- a/lib/dispatch/pr-context.ts +++ b/lib/dispatch/pr-context.ts @@ -8,6 +8,7 @@ */ import type { IssueProvider } from "../providers/provider.js"; import { PrState } from "../providers/provider.js"; +import { resolveCanonicalPrForIssue } from "../services/canonical-pr.js"; // --------------------------------------------------------------------------- // Types @@ -32,8 +33,9 @@ export type PrContext = { /** * Fetch PR review feedback for an issue returning from review. - * Returns undefined if no PR or no review comments found. - * Best-effort: swallows errors (caller can still work from issue context). + * Returns undefined if no PR found, or if the PR is not currently in a + * feedback-worthy state. + * Canonical routing errors are allowed to bubble so dispatch fails closed. * * Includes explicit branch name in feedback to prevent developers from working * on the wrong PR when multiple PRs exist for the same issue (#482). @@ -41,50 +43,70 @@ export type PrContext = { export async function fetchPrFeedback( provider: IssueProvider, issueId: number, + opts?: { workspaceDir?: string; projectSlug?: string }, ): Promise { - try { - const prStatus = await provider.getPrStatus(issueId); - if (!prStatus.url || prStatus.state === PrState.MERGED || prStatus.state === PrState.CLOSED) { - return undefined; - } - const reviewComments = await provider.getPrReviewComments(issueId); - if (reviewComments.length === 0) return undefined; - - const reason = prStatus.mergeable === false ? "merge_conflict" as const - : (prStatus.state === PrState.CHANGES_REQUESTED || prStatus.state === PrState.HAS_COMMENTS) ? "changes_requested" as const - : "rejected" as const; - - return { - url: prStatus.url, - branchName: prStatus.sourceBranch, - reason, - comments: reviewComments.map((c) => ({ - id: c.id, author: c.author, body: c.body, state: c.state, - path: c.path, line: c.line, - })), - }; - } catch { + let canonicalUrl: string | undefined; + if (opts?.workspaceDir && opts?.projectSlug) { + canonicalUrl = (await resolveCanonicalPrForIssue({ workspaceDir: opts.workspaceDir, projectSlug: opts.projectSlug, issueId, provider, allowBackfill: false })).url; + } + + const prStatus = canonicalUrl + ? await provider.getPrStatusByUrl(canonicalUrl) + : await provider.getPrStatus(issueId); + if (canonicalUrl && !prStatus) { + throw new Error(`Canonical PR routing integrity failure for issue #${issueId}: stored PR ${canonicalUrl} no longer resolves.`); + } + if (!prStatus?.url || prStatus.state === PrState.MERGED || prStatus.state === PrState.CLOSED) { return undefined; } + + const reason = prStatus.mergeable === false ? "merge_conflict" as const + : (prStatus.state === PrState.CHANGES_REQUESTED || prStatus.state === PrState.HAS_COMMENTS) ? "changes_requested" as const + : undefined; + if (!reason) return undefined; + + const reviewComments = opts?.workspaceDir && opts?.projectSlug + ? await provider.getPrReviewCommentsByUrl(prStatus.url) + : await provider.getPrReviewComments(issueId); + + return { + url: prStatus.url, + branchName: prStatus.sourceBranch, + reason, + comments: reviewComments.map((c) => ({ + id: c.id, author: c.author, body: c.body, state: c.state, + path: c.path, line: c.line, + })), + }; } /** * Fetch PR context (URL + diff) for code review. * Returns undefined if no PR found. - * Best-effort: swallows errors (caller can still work from issue context). + * Canonical routing errors are allowed to bubble so dispatch fails closed. */ export async function fetchPrContext( provider: IssueProvider, issueId: number, + opts?: { workspaceDir?: string; projectSlug?: string }, ): Promise { - try { - const prStatus = await provider.getPrStatus(issueId); - if (!prStatus.url) return undefined; - const diff = await provider.getPrDiff(issueId) ?? undefined; - return { url: prStatus.url, diff }; - } catch { - return undefined; + const canonicalRouting = !!(opts?.workspaceDir && opts?.projectSlug); + const canonicalUrl = canonicalRouting + ? (await resolveCanonicalPrForIssue({ workspaceDir: opts.workspaceDir!, projectSlug: opts.projectSlug!, issueId, provider, allowBackfill: false })).url + : undefined; + const prStatus = canonicalUrl + ? await provider.getPrStatusByUrl(canonicalUrl) + : await provider.getPrStatus(issueId); + if (canonicalUrl && !prStatus) { + throw new Error(`Canonical PR routing integrity failure for issue #${issueId}: stored PR ${canonicalUrl} no longer resolves.`); } + if (!prStatus?.url) return undefined; + + const diff = canonicalRouting + ? await provider.getPrDiffByUrl(prStatus.url) ?? undefined + : await provider.getPrDiff(issueId) ?? undefined; + + return { url: prStatus.url, diff }; } // --------------------------------------------------------------------------- @@ -110,8 +132,6 @@ export function formatPrContext(prContext: PrContext): string[] { * Format PR review feedback section for task message. */ export function formatPrFeedback(prFeedback: PrFeedback, baseBranch: string): string[] { - if (prFeedback.comments.length === 0) return []; - const reasonLabel = prFeedback.reason === "merge_conflict" ? "⚠️ Merge conflicts detected" : prFeedback.reason === "changes_requested" @@ -124,9 +144,13 @@ export function formatPrFeedback(prFeedback: PrFeedback, baseBranch: string): st `🔗 ${prFeedback.url}`, ]; - for (const c of prFeedback.comments) { - const location = c.path ? ` (${c.path}${c.line ? `:${c.line}` : ""})` : ""; - parts.push(``, `**${c.author}** [${c.state}]${location}:`, c.body); + if (prFeedback.comments.length > 0) { + for (const c of prFeedback.comments) { + const location = c.path ? ` (${c.path}${c.line ? `:${c.line}` : ""})` : ""; + parts.push(``, `**${c.author}** [${c.state}]${location}:`, c.body); + } + } else { + parts.push(``, `_No review comment bodies were retrieved, but this canonical PR still needs attention._`); } if (prFeedback.reason === "merge_conflict") { @@ -135,7 +159,7 @@ export function formatPrFeedback(prFeedback: PrFeedback, baseBranch: string): st parts.push( ``, `### Conflict Resolution Instructions`, ``, - `**Important:** You must update the EXISTING PR branch, not create a new one.`, + `**Important:** You must update the EXISTING canonical PR branch, not create a new one.`, ``, `🔹 PR: ${prFeedback.url}`, `🔹 Branch: \`${branchName}\``, @@ -174,7 +198,7 @@ export function formatPrFeedback(prFeedback: PrFeedback, baseBranch: string): st ` # Status should be "Mergeable" or "Open"`, ` \`\`\``, ``, - `⚠️ Do NOT create a new PR. Do NOT switch branches. Update THIS PR only.`, + `⚠️ Do NOT create a new PR unless the task explicitly calls for replacement. Do NOT switch branches. Update THIS canonical PR only.`, ); } diff --git a/lib/providers/github.ts b/lib/providers/github.ts index cc7d4b67..db7676c0 100644 --- a/lib/providers/github.ts +++ b/lib/providers/github.ts @@ -6,6 +6,7 @@ import { type Issue, type StateLabel, type IssueComment, + type PrIdentity, type PrStatus, type PrReviewComment, PrState, @@ -42,6 +43,14 @@ export class GitHubProvider implements IssueProvider { private runCommand: RunCommand; private targetRepo?: string; + private isGhNotFoundError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return message.includes("Could not resolve to a PullRequest") || + message.includes("no pull requests found") || + message.includes("HTTP 404") || + message.includes("Not Found"); + } + constructor(opts: { repoPath: string; runCommand: RunCommand; workflow?: WorkflowConfig; target?: ProviderTarget }) { this.repoPath = opts.repoPath; this.runCommand = opts.runCommand; @@ -341,41 +350,57 @@ export class GitHubProvider implements IssueProvider { return prs[0].url; } + async getLinkedPrs(issueId: number): Promise { + const prs = await this.findPrsForIssue<{ number: number; title: string; body: string; headRefName: string; url: string }>( + issueId, + "all", + "number,title,body,headRefName,url", + ); + return prs.map((pr) => ({ + number: pr.number, + url: pr.url, + title: pr.title, + sourceBranch: pr.headRefName, + repo: this.targetRepo, + })); + } + + async getPrByUrl(prUrl: string): Promise { + try { + const raw = await this.gh(["pr", "view", prUrl, "--json", "number,title,headRefName,url"]); + const pr = JSON.parse(raw) as { number: number; title: string; headRefName: string; url: string }; + return { number: pr.number, url: pr.url, title: pr.title, sourceBranch: pr.headRefName, repo: this.targetRepo }; + } catch (err) { + if (this.isGhNotFoundError(err)) return null; + throw err; + } + } + + async getPrByNumber(prNumber: number): Promise { + try { + const raw = await this.gh(["pr", "view", String(prNumber), "--json", "number,title,headRefName,url"]); + const pr = JSON.parse(raw) as { number: number; title: string; headRefName: string; url: string }; + return { number: pr.number, url: pr.url, title: pr.title, sourceBranch: pr.headRefName, repo: this.targetRepo }; + } catch (err) { + if (this.isGhNotFoundError(err)) return null; + throw err; + } + } + async getPrStatus(issueId: number): Promise { // Check open PRs first — include mergeable for conflict detection type OpenPr = { title: string; body: string; headRefName: string; url: string; number: number; reviewDecision: string; mergeable: string }; const open = await this.findPrsForIssue(issueId, "open", "title,body,headRefName,url,number,reviewDecision,mergeable"); if (open.length > 0) { const pr = open[0]; - let state: PrState; - if (pr.reviewDecision === "APPROVED") { - state = PrState.APPROVED; - } else if (pr.reviewDecision === "CHANGES_REQUESTED") { - state = PrState.CHANGES_REQUESTED; - } else { - // No branch protection → reviewDecision may be empty. Check individual reviews. - const hasChangesRequested = await this.hasChangesRequestedReview(pr.number); - if (hasChangesRequested) { - state = PrState.CHANGES_REQUESTED; - } else { - // Check for unacknowledged COMMENTED reviews (feedback without formal "Request changes") - const hasReviewFeedback = await this.hasUnacknowledgedReviews(pr.number); - if (hasReviewFeedback) { - state = PrState.HAS_COMMENTS; - } else { - // Fall through to conversation comment detection - const hasComments = await this.hasConversationComments(pr.number); - state = hasComments ? PrState.HAS_COMMENTS : PrState.OPEN; - } - } - } - - // Conflict detection: "CONFLICTING" means merge conflicts, "UNKNOWN" means still computing - const mergeable = pr.mergeable === "CONFLICTING" ? false - : pr.mergeable === "MERGEABLE" ? true - : undefined; // UNKNOWN or missing — don't assume - - return { state, url: pr.url, title: pr.title, sourceBranch: pr.headRefName, mergeable }; + return this.buildOpenPrStatus({ + number: pr.number, + title: pr.title, + sourceBranch: pr.headRefName, + url: pr.url, + reviewDecision: pr.reviewDecision, + mergeable: pr.mergeable, + }); } // Check merged PRs — also fetch reviewDecision to detect approved-then-merged vs self-merged. type MergedPr = { title: string; body: string; headRefName: string; url: string; reviewDecision: string | null }; @@ -390,11 +415,63 @@ export class GitHubProvider implements IssueProvider { const allPrs = await this.findPrsViaTimeline(issueId, "all"); const closedPr = allPrs?.find((pr) => pr.state === "CLOSED"); if (closedPr) { - return { state: PrState.CLOSED, url: closedPr.url, title: closedPr.title, sourceBranch: closedPr.headRefName }; + return { state: PrState.CLOSED, url: closedPr.url, number: closedPr.number, title: closedPr.title, sourceBranch: closedPr.headRefName }; } return { state: PrState.CLOSED, url: null }; } + async getPrStatusByUrl(prUrl: string): Promise { + try { + const raw = await this.gh(["pr", "view", prUrl, "--json", "number,title,headRefName,url,state,reviewDecision,mergeable"]); + const pr = JSON.parse(raw) as { number: number; title: string; headRefName: string; url: string; state: string; reviewDecision: string | null; mergeable: string | null }; + if (pr.state === "MERGED") { + return { state: PrState.MERGED, url: pr.url, number: pr.number, title: pr.title, sourceBranch: pr.headRefName }; + } + if (pr.state === "CLOSED") { + return { state: PrState.CLOSED, url: pr.url, number: pr.number, title: pr.title, sourceBranch: pr.headRefName }; + } + return this.buildOpenPrStatus({ + number: pr.number, + title: pr.title, + sourceBranch: pr.headRefName, + url: pr.url, + reviewDecision: pr.reviewDecision, + mergeable: pr.mergeable, + }); + } catch (err) { + if (this.isGhNotFoundError(err)) return null; + throw err; + } + } + + private async buildOpenPrStatus(pr: { number: number; title: string; sourceBranch?: string; url: string; reviewDecision?: string | null; mergeable?: string | null }): Promise { + let state: PrState; + if (pr.reviewDecision === "APPROVED") { + state = PrState.APPROVED; + } else if (pr.reviewDecision === "CHANGES_REQUESTED") { + state = PrState.CHANGES_REQUESTED; + } else { + const hasChangesRequested = await this.hasChangesRequestedReview(pr.number); + if (hasChangesRequested) { + state = PrState.CHANGES_REQUESTED; + } else { + const hasReviewFeedback = await this.hasUnacknowledgedReviews(pr.number); + if (hasReviewFeedback) { + state = PrState.HAS_COMMENTS; + } else { + const hasComments = await this.hasConversationComments(pr.number); + state = hasComments ? PrState.HAS_COMMENTS : PrState.OPEN; + } + } + } + + const mergeable = pr.mergeable === "CONFLICTING" ? false + : pr.mergeable === "MERGEABLE" ? true + : undefined; + + return { state, url: pr.url, number: pr.number, title: pr.title, sourceBranch: pr.sourceBranch, mergeable }; + } + /** * Check individual reviews for CHANGES_REQUESTED state. * Used when branch protection is disabled (reviewDecision is empty). @@ -482,11 +559,12 @@ export class GitHubProvider implements IssueProvider { } catch { return []; } } - async mergePr(issueId: number): Promise { - type OpenPr = { title: string; body: string; headRefName: string; url: string }; - const prs = await this.findPrsForIssue(issueId, "open", "title,body,headRefName,url"); - if (prs.length === 0) throw new Error(`No open PR found for issue #${issueId}`); - await this.gh(["pr", "merge", prs[0].url, "--merge"]); + async mergePr(issueId: number, opts?: { prUrl?: string; prNumber?: number }): Promise { + const prUrl = opts?.prUrl ?? (opts?.prNumber ? (await this.getPrByNumber(opts.prNumber))?.url : null); + if (!prUrl) { + throw new Error(`Canonical PR identity is required to merge issue #${issueId}.`); + } + await this.gh(["pr", "merge", prUrl, "--merge"]); } async getPrDiff(issueId: number): Promise { @@ -498,68 +576,69 @@ export class GitHubProvider implements IssueProvider { } catch { return null; } } - async getPrReviewComments(issueId: number): Promise { - type OpenPr = { title: string; body: string; headRefName: string; number: number }; - const prs = await this.findPrsForIssue(issueId, "open", "title,body,headRefName,number"); - if (prs.length === 0) return []; - const prNumber = prs[0].number; + async getPrDiffByUrl(prUrl: string): Promise { + const pr = await this.getPrByUrl(prUrl); + if (!pr) return null; + return await this.gh(["pr", "diff", String(pr.number)]); + } + + private async getReviewCommentsForPrNumber(prNumber: number, opts?: { strict?: boolean }): Promise { + const strict = opts?.strict ?? false; const comments: PrReviewComment[] = []; try { - // Review-level comments (top-level reviews: APPROVED, CHANGES_REQUESTED, COMMENTED) const reviewsRaw = await this.gh(["api", `repos/:owner/:repo/pulls/${prNumber}/reviews`]); const reviews = JSON.parse(reviewsRaw) as Array<{ id: number; user: { login: string }; body: string; state: string; submitted_at: string; }>; for (const r of reviews) { - if (r.state === "DISMISSED") continue; // Skip dismissed - if (!r.body && r.state === "COMMENTED") continue; // Skip empty COMMENTED reviews - comments.push({ - id: r.id, - author: r.user.login, - body: r.body ?? "", - state: r.state, - created_at: r.submitted_at, - }); + if (r.state === "DISMISSED") continue; + if (!r.body && r.state === "COMMENTED") continue; + comments.push({ id: r.id, author: r.user.login, body: r.body ?? "", state: r.state, created_at: r.submitted_at }); } - } catch { /* best-effort */ } + } catch (err) { + if (strict) throw err; + } try { - // Inline (file-level) review comments const inlineRaw = await this.gh(["api", `repos/:owner/:repo/pulls/${prNumber}/comments`]); const inlines = JSON.parse(inlineRaw) as Array<{ id: number; user: { login: string }; body: string; path: string; line: number | null; created_at: string; }>; for (const c of inlines) { - comments.push({ - id: c.id, - author: c.user.login, - body: c.body, - state: "INLINE", - created_at: c.created_at, - path: c.path, - line: c.line ?? undefined, - }); + comments.push({ id: c.id, author: c.user.login, body: c.body, state: "INLINE", created_at: c.created_at, path: c.path, line: c.line ?? undefined }); } - } catch { /* best-effort */ } + } catch (err) { + if (strict) throw err; + } - // Top-level conversation comments (regular PR comments via Issues API) - const conversationComments = await this.fetchConversationComments(prNumber); + let conversationComments: Array<{ id: number; user: { login: string }; body: string; created_at: string }> = []; + try { + conversationComments = await this.fetchConversationComments(prNumber); + } catch (err) { + if (strict) throw err; + } for (const c of conversationComments) { - comments.push({ - id: c.id, - author: c.user.login, - body: c.body, - state: "COMMENTED", - created_at: c.created_at, - }); + comments.push({ id: c.id, author: c.user.login, body: c.body, state: "COMMENTED", created_at: c.created_at }); } - // Sort by date comments.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime()); return comments; } + async getPrReviewComments(issueId: number): Promise { + type OpenPr = { title: string; body: string; headRefName: string; number: number }; + const prs = await this.findPrsForIssue(issueId, "open", "title,body,headRefName,number"); + if (prs.length === 0) return []; + return this.getReviewCommentsForPrNumber(prs[0].number); + } + + async getPrReviewCommentsByUrl(prUrl: string): Promise { + const pr = await this.getPrByUrl(prUrl); + if (!pr) return []; + return this.getReviewCommentsForPrNumber(pr.number, { strict: true }); + } + async addComment(issueId: number, body: string): Promise { const raw = await this.gh([ "api", `repos/:owner/:repo/issues/${issueId}/comments`, diff --git a/lib/providers/gitlab.ts b/lib/providers/gitlab.ts index 16ff17b6..a702b084 100644 --- a/lib/providers/gitlab.ts +++ b/lib/providers/gitlab.ts @@ -6,6 +6,7 @@ import { type Issue, type StateLabel, type IssueComment, + type PrIdentity, type PrStatus, type PrReviewComment, PrState, @@ -38,6 +39,11 @@ export class GitLabProvider implements IssueProvider { private runCommand: RunCommand; private targetRepo?: string; + private isGlabNotFoundError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return message.includes("404") || message.includes("Not Found") || message.includes("Merge request not found"); + } + constructor(opts: { repoPath: string; runCommand: RunCommand; workflow?: WorkflowConfig; target?: ProviderTarget }) { this.repoPath = opts.repoPath; this.runCommand = opts.runCommand; @@ -195,43 +201,97 @@ export class GitLabProvider implements IssueProvider { return merged[0]?.web_url ?? null; } + async getLinkedPrs(issueId: number): Promise { + const mrs = await this.getRelatedMRs(issueId); + return mrs.map((mr) => ({ number: mr.iid, url: mr.web_url, title: mr.title, sourceBranch: mr.source_branch, repo: this.targetRepo })); + } + + async getPrByUrl(prUrl: string): Promise { + try { + const raw = await this.glab(["mr", "view", prUrl, "--output", "json"]); + const mr = JSON.parse(raw) as { iid: number; web_url: string; title: string; source_branch?: string }; + return { number: mr.iid, url: mr.web_url, title: mr.title, sourceBranch: mr.source_branch, repo: this.targetRepo }; + } catch (err) { + if (this.isGlabNotFoundError(err)) return null; + throw err; + } + } + + async getPrByNumber(prNumber: number): Promise { + try { + const raw = await this.glab(["mr", "view", String(prNumber), "--output", "json"]); + const mr = JSON.parse(raw) as { iid: number; web_url: string; title: string; source_branch?: string }; + return { number: mr.iid, url: mr.web_url, title: mr.title, sourceBranch: mr.source_branch, repo: this.targetRepo }; + } catch (err) { + if (this.isGlabNotFoundError(err)) return null; + throw err; + } + } + async getPrStatus(issueId: number): Promise { const mrs = await this.getRelatedMRs(issueId); // Check open MRs first const open = mrs.find((mr) => mr.state === "opened"); if (open) { - const approved = await this.isMrApproved(open.iid); - - // Detect changes requested via unresolved discussion threads - let state: PrState; - if (approved) { - state = PrState.APPROVED; - } else { - const hasUnresolved = await this.hasUnresolvedDiscussions(open.iid); - if (hasUnresolved) { - state = PrState.CHANGES_REQUESTED; - } else { - // Check for top-level conversation comments from non-author users - const hasComments = await this.hasConversationComments(open.iid); - state = hasComments ? PrState.HAS_COMMENTS : PrState.OPEN; - } - } - - // Detect merge conflicts - const mergeable = await this.isMrMergeable(open.iid); - - return { state, url: open.web_url, title: open.title, sourceBranch: open.source_branch, mergeable }; + return this.buildOpenMrStatus({ + iid: open.iid, + title: open.title, + sourceBranch: open.source_branch, + url: open.web_url, + }); } // Check merged MRs const merged = mrs.find((mr) => mr.state === "merged"); - if (merged) return { state: PrState.MERGED, url: merged.web_url, title: merged.title, sourceBranch: merged.source_branch }; + if (merged) return { state: PrState.MERGED, url: merged.web_url, number: merged.iid, title: merged.title, sourceBranch: merged.source_branch }; // Check for closed-without-merge MRs. url: non-null = MR was explicitly closed; // url: null = no MR has ever been created for this issue. const closed = mrs.find((mr) => mr.state === "closed"); - if (closed) return { state: PrState.CLOSED, url: closed.web_url, title: closed.title, sourceBranch: closed.source_branch }; + if (closed) return { state: PrState.CLOSED, url: closed.web_url, number: closed.iid, title: closed.title, sourceBranch: closed.source_branch }; return { state: PrState.CLOSED, url: null }; } + async getPrStatusByUrl(prUrl: string): Promise { + const pr = await this.getPrByUrl(prUrl); + if (!pr) return null; + try { + const raw = await this.glab(["api", `projects/:id/merge_requests/${pr.number}?include_rebase_in_progress=true`]); + const mr = JSON.parse(raw) as { state: string; title?: string; source_branch?: string; web_url?: string }; + const url = mr.web_url ?? pr.url; + const title = mr.title ?? pr.title ?? pr.url; + const sourceBranch = mr.source_branch ?? pr.sourceBranch; + if (mr.state === "merged") { + return { state: PrState.MERGED, url, number: pr.number, title, sourceBranch }; + } + if (mr.state === "closed") { + return { state: PrState.CLOSED, url, number: pr.number, title, sourceBranch }; + } + return this.buildOpenMrStatus({ iid: pr.number, title, sourceBranch, url }); + } catch (err) { + if (this.isGlabNotFoundError(err)) return null; + throw err; + } + } + + private async buildOpenMrStatus(pr: { iid: number; title: string; sourceBranch?: string; url: string }): Promise { + const approved = await this.isMrApproved(pr.iid); + + let state: PrState; + if (approved) { + state = PrState.APPROVED; + } else { + const hasUnresolved = await this.hasUnresolvedDiscussions(pr.iid); + if (hasUnresolved) { + state = PrState.CHANGES_REQUESTED; + } else { + const hasComments = await this.hasConversationComments(pr.iid); + state = hasComments ? PrState.HAS_COMMENTS : PrState.OPEN; + } + } + + const mergeable = await this.isMrMergeable(pr.iid); + return { state, url: pr.url, number: pr.iid, title: pr.title, sourceBranch: pr.sourceBranch, mergeable }; + } + /** Check if an MR has unresolved discussion threads (proxy for changes requested). */ private async hasUnresolvedDiscussions(mrIid: number): Promise { try { @@ -316,11 +376,12 @@ export class GitLabProvider implements IssueProvider { } catch { return false; } } - async mergePr(issueId: number): Promise { - const mrs = await this.getRelatedMRs(issueId); - const open = mrs.find((mr) => mr.state === "opened"); - if (!open) throw new Error(`No open MR found for issue #${issueId}`); - await this.glab(["mr", "merge", String(open.iid)]); + async mergePr(issueId: number, opts?: { prUrl?: string; prNumber?: number }): Promise { + const explicit = opts?.prNumber ?? (opts?.prUrl ? (await this.getPrByUrl(opts.prUrl))?.number : undefined); + if (!explicit) { + throw new Error(`Canonical PR identity is required to merge issue #${issueId}.`); + } + await this.glab(["mr", "merge", String(explicit)]); } async getPrDiff(issueId: number): Promise { @@ -332,14 +393,31 @@ export class GitLabProvider implements IssueProvider { } catch { return null; } } + async getPrDiffByUrl(prUrl: string): Promise { + const pr = await this.getPrByUrl(prUrl); + if (!pr) return null; + return await this.glab(["mr", "diff", String(pr.number)]); + } + async getPrReviewComments(issueId: number): Promise { const mrs = await this.getRelatedMRs(issueId); const open = mrs.find((mr) => mr.state === "opened"); if (!open) return []; + return this.getReviewCommentsForMrIid(open.iid); + } + + async getPrReviewCommentsByUrl(prUrl: string): Promise { + const pr = await this.getPrByUrl(prUrl); + if (!pr) return []; + return this.getReviewCommentsForMrIid(pr.number, { strict: true }); + } + + private async getReviewCommentsForMrIid(mrIid: number, opts?: { strict?: boolean }): Promise { + const strict = opts?.strict ?? false; const comments: PrReviewComment[] = []; try { - const raw = await this.glab(["api", `projects/:id/merge_requests/${open.iid}/discussions`]); + const raw = await this.glab(["api", `projects/:id/merge_requests/${mrIid}/discussions`]); const discussions = JSON.parse(raw) as Array<{ notes: Array<{ id: number; author: { username: string }; body: string; @@ -362,12 +440,17 @@ export class GitLabProvider implements IssueProvider { }); } } - } catch { /* best-effort */ } + } catch (err) { + if (strict) throw err; + } - // Also include top-level conversation notes (regular MR comments, not threaded) - const conversationNotes = await this.fetchConversationComments(open.iid); + let conversationNotes: Array<{ id: number; author: { username: string }; body: string; created_at: string }> = []; + try { + conversationNotes = await this.fetchConversationComments(mrIid); + } catch (err) { + if (strict) throw err; + } for (const n of conversationNotes) { - // Avoid duplicates: discussions endpoint may already include these if (!comments.some((c) => c.id === n.id)) { comments.push({ id: n.id, diff --git a/lib/providers/provider-pr-status.test.ts b/lib/providers/provider-pr-status.test.ts index 4570dc55..8ef8d875 100644 --- a/lib/providers/provider-pr-status.test.ts +++ b/lib/providers/provider-pr-status.test.ts @@ -6,7 +6,7 @@ * * Run with: npx tsx --test lib/providers/provider-pr-status.test.ts */ -import { describe, it, mock } from "node:test"; +import { describe, it } from "node:test"; import assert from "node:assert"; import type { RunCommand } from "../context.js"; import { GitHubProvider } from "./github.js"; @@ -26,7 +26,6 @@ describe("GitHubProvider.getPrStatus — closed PR handling", () => { it("returns url:null when no PR has ever been created", async () => { const provider = new GitHubProvider({ repoPath: "/fake", runCommand: mockRunCommand }); - // findPrsForIssue returns [] for open and merged, findPrsViaTimeline returns null (GraphQL unavailable) (provider as any).findPrsForIssue = async () => []; (provider as any).findPrsViaTimeline = async () => null; @@ -103,7 +102,6 @@ describe("GitHubProvider.getPrStatus — closed PR handling", () => { } return []; }; - // Simulate no changes-requested reviews and no comments (provider as any).hasChangesRequestedReview = async () => false; (provider as any).hasUnacknowledgedReviews = async () => false; (provider as any).hasConversationComments = async () => false; @@ -146,7 +144,6 @@ describe("GitHubProvider.getPrStatus — closed PR handling", () => { const provider = new GitHubProvider({ repoPath: "/fake", runCommand: mockRunCommand }); (provider as any).findPrsForIssue = async () => []; - // Timeline has only OPEN PRs — none should trigger closed-PR path (provider as any).findPrsViaTimeline = async (_id: number, state: string) => { if (state === "all") { return [{ number: 10, title: "", body: "", headRefName: "", url: "https://github.com/owner/repo/pull/10", mergedAt: null, reviewDecision: null, state: "OPEN", mergeable: null }]; @@ -156,8 +153,6 @@ describe("GitHubProvider.getPrStatus — closed PR handling", () => { const status = await provider.getPrStatus(42); - // OPEN PR in timeline but findPrsForIssue("open") returned [] → shouldn't reach here normally, - // but the CLOSED fallback path should not pick it up. assert.strictEqual(status.state, PrState.CLOSED); assert.strictEqual(status.url, null, "OPEN state in timeline should not match closed-PR path"); }); @@ -183,7 +178,6 @@ describe("GitHubProvider.getPrStatus — closed PR handling", () => { } return []; }; - // Simulate no changes-requested reviews and no comments (provider as any).hasChangesRequestedReview = async () => false; (provider as any).hasUnacknowledgedReviews = async () => false; (provider as any).hasConversationComments = async () => false; @@ -260,6 +254,92 @@ describe("GitHubProvider.getPrStatus — closed PR handling", () => { }); }); +describe("GitHubProvider.getPrStatusByUrl", () => { + it("preserves comment-only feedback semantics for canonical PR routing", async () => { + const provider = new GitHubProvider({ repoPath: "/fake", runCommand: mockRunCommand }); + + (provider as any).gh = async () => JSON.stringify({ + number: 44, + title: "feat: canonical pr", + headRefName: "issue/244-canonical-pr-ledger", + url: "https://github.com/owner/repo/pull/44", + state: "OPEN", + reviewDecision: null, + mergeable: "MERGEABLE", + }); + (provider as any).hasChangesRequestedReview = async () => false; + (provider as any).hasUnacknowledgedReviews = async () => true; + (provider as any).hasConversationComments = async () => false; + + const status = await provider.getPrStatusByUrl("https://github.com/owner/repo/pull/44"); + + assert.ok(status); + assert.strictEqual(status.state, PrState.HAS_COMMENTS); + assert.strictEqual(status.mergeable, true); + }); + + it("preserves changes-requested fallback when reviewDecision is empty", async () => { + const provider = new GitHubProvider({ repoPath: "/fake", runCommand: mockRunCommand }); + + (provider as any).gh = async () => JSON.stringify({ + number: 45, + title: "feat: canonical pr", + headRefName: "issue/244-canonical-pr-ledger", + url: "https://github.com/owner/repo/pull/45", + state: "OPEN", + reviewDecision: null, + mergeable: "UNKNOWN", + }); + (provider as any).hasChangesRequestedReview = async () => true; + (provider as any).hasUnacknowledgedReviews = async () => false; + (provider as any).hasConversationComments = async () => false; + + const status = await provider.getPrStatusByUrl("https://github.com/owner/repo/pull/45"); + + assert.ok(status); + assert.strictEqual(status.state, PrState.CHANGES_REQUESTED); + assert.strictEqual(status.mergeable, undefined); + }); +}); + +describe("GitHubProvider canonical URL helpers", () => { + it("throws when diff lookup fails after PR identity resolves", async () => { + const provider = new GitHubProvider({ repoPath: "/fake", runCommand: mockRunCommand }); + (provider as any).getPrByUrl = async () => ({ + number: 46, + url: "https://github.com/owner/repo/pull/46", + title: "feat: canonical pr", + sourceBranch: "issue/244-canonical-pr-ledger", + }); + (provider as any).gh = async () => { + throw new Error("gh diff failed"); + }; + + await assert.rejects( + provider.getPrDiffByUrl("https://github.com/owner/repo/pull/46"), + /gh diff failed/, + ); + }); + + it("throws when review comment retrieval fails after PR identity resolves", async () => { + const provider = new GitHubProvider({ repoPath: "/fake", runCommand: mockRunCommand }); + (provider as any).getPrByUrl = async () => ({ + number: 47, + url: "https://github.com/owner/repo/pull/47", + title: "feat: canonical pr", + sourceBranch: "issue/244-canonical-pr-ledger", + }); + (provider as any).gh = async () => { + throw new Error("gh reviews failed"); + }; + + await assert.rejects( + provider.getPrReviewCommentsByUrl("https://github.com/owner/repo/pull/47"), + /gh reviews failed/, + ); + }); +}); + // --------------------------------------------------------------------------- // GitLab provider tests // --------------------------------------------------------------------------- @@ -352,7 +432,168 @@ describe("GitLabProvider.getPrStatus — closed MR handling", () => { const status = await provider.getPrStatus(42); assert.strictEqual(status.state, PrState.CLOSED); - // First closed MR found is returned assert.strictEqual(status.url, closedMrUrl1); }); }); + +describe("GitLabProvider.getPrStatusByUrl", () => { + it("preserves comment-driven feedback semantics for canonical MR routing", async () => { + const provider = new GitLabProvider({ repoPath: "/fake", runCommand: mockRunCommand }); + + (provider as any).getPrByUrl = async () => ({ + number: 24, + url: "https://gitlab.com/owner/repo/-/merge_requests/24", + title: "feat: canonical mr", + sourceBranch: "issue/244-canonical-pr-ledger", + }); + (provider as any).glab = async () => JSON.stringify({ + state: "opened", + title: "feat: canonical mr", + source_branch: "issue/244-canonical-pr-ledger", + web_url: "https://gitlab.com/owner/repo/-/merge_requests/24", + }); + (provider as any).isMrApproved = async () => false; + (provider as any).hasUnresolvedDiscussions = async () => false; + (provider as any).hasConversationComments = async () => true; + (provider as any).isMrMergeable = async () => true; + + const status = await provider.getPrStatusByUrl("https://gitlab.com/owner/repo/-/merge_requests/24"); + + assert.ok(status); + assert.strictEqual(status.state, PrState.HAS_COMMENTS); + assert.strictEqual(status.mergeable, true); + }); + + it("preserves unresolved-discussion changes-requested semantics", async () => { + const provider = new GitLabProvider({ repoPath: "/fake", runCommand: mockRunCommand }); + + (provider as any).getPrByUrl = async () => ({ + number: 25, + url: "https://gitlab.com/owner/repo/-/merge_requests/25", + title: "feat: canonical mr", + sourceBranch: "issue/244-canonical-pr-ledger", + }); + (provider as any).glab = async () => JSON.stringify({ + state: "opened", + title: "feat: canonical mr", + source_branch: "issue/244-canonical-pr-ledger", + web_url: "https://gitlab.com/owner/repo/-/merge_requests/25", + }); + (provider as any).isMrApproved = async () => false; + (provider as any).hasUnresolvedDiscussions = async () => true; + (provider as any).hasConversationComments = async () => false; + (provider as any).isMrMergeable = async () => undefined; + + const status = await provider.getPrStatusByUrl("https://gitlab.com/owner/repo/-/merge_requests/25"); + + assert.ok(status); + assert.strictEqual(status.state, PrState.CHANGES_REQUESTED); + assert.strictEqual(status.mergeable, undefined); + }); +}); + +describe("GitLabProvider.getPrReviewCommentsByUrl", () => { + it("reuses canonical MR comment retrieval semantics", async () => { + const provider = new GitLabProvider({ repoPath: "/fake", runCommand: mockRunCommand }); + + (provider as any).getPrByUrl = async () => ({ + number: 26, + url: "https://gitlab.com/owner/repo/-/merge_requests/26", + title: "feat: canonical mr", + sourceBranch: "issue/244-canonical-pr-ledger", + }); + (provider as any).glab = async ([, path]: string[]) => { + if (path === "projects/:id/merge_requests/26/discussions") { + return JSON.stringify([ + { + notes: [ + { + id: 101, + author: { username: "reviewer" }, + body: "Please tighten this up", + resolvable: true, + resolved: false, + system: false, + created_at: "2026-05-31T00:00:00Z", + position: { new_path: "lib/providers/gitlab.ts", new_line: 451 }, + }, + ], + }, + ]); + } + if (path === "projects/:id/merge_requests/26/notes") { + return JSON.stringify([ + { + id: 102, + author: { username: "reviewer" }, + system: false, + body: "Top-level follow-up", + created_at: "2026-05-31T00:01:00Z", + }, + ]); + } + throw new Error(`unexpected glab path: ${path}`); + }; + + const comments = await provider.getPrReviewCommentsByUrl("https://gitlab.com/owner/repo/-/merge_requests/26"); + + assert.deepStrictEqual(comments, [ + { + id: 101, + author: "reviewer", + body: "Please tighten this up", + state: "UNRESOLVED", + created_at: "2026-05-31T00:00:00Z", + path: "lib/providers/gitlab.ts", + line: 451, + }, + { + id: 102, + author: "reviewer", + body: "Top-level follow-up", + state: "COMMENTED", + created_at: "2026-05-31T00:01:00Z", + }, + ]); + }); + + it("throws when canonical MR comment retrieval fails after identity resolution", async () => { + const provider = new GitLabProvider({ repoPath: "/fake", runCommand: mockRunCommand }); + + (provider as any).getPrByUrl = async () => ({ + number: 27, + url: "https://gitlab.com/owner/repo/-/merge_requests/27", + title: "feat: canonical mr", + sourceBranch: "issue/244-canonical-pr-ledger", + }); + (provider as any).glab = async () => { + throw new Error("glab discussions failed"); + }; + + await assert.rejects( + provider.getPrReviewCommentsByUrl("https://gitlab.com/owner/repo/-/merge_requests/27"), + /glab discussions failed/, + ); + }); +}); + +describe("GitLabProvider canonical URL helpers", () => { + it("throws when diff lookup fails after MR identity resolves", async () => { + const provider = new GitLabProvider({ repoPath: "/fake", runCommand: mockRunCommand }); + + (provider as any).getPrByUrl = async () => ({ + number: 28, + url: "https://gitlab.com/owner/repo/-/merge_requests/28", + title: "feat: canonical mr", + sourceBranch: "issue/244-canonical-pr-ledger", + }); + (provider as any).glab = async () => { + throw new Error("glab diff failed"); + }; + + await assert.rejects( + provider.getPrDiffByUrl("https://gitlab.com/owner/repo/-/merge_requests/28"), + /glab diff failed/, + ); + }); +}); diff --git a/lib/providers/provider.ts b/lib/providers/provider.ts index bd0593bb..072c3d1e 100644 --- a/lib/providers/provider.ts +++ b/lib/providers/provider.ts @@ -44,6 +44,8 @@ export type PrState = (typeof PrState)[keyof typeof PrState]; export type PrStatus = { state: PrState; url: string | null; + /** Provider-native PR/MR number or IID. */ + number?: number; /** MR/PR title (e.g. "feat: add login page"). */ title?: string; /** Source branch name (e.g. "feature/7-blog-cms"). */ @@ -52,6 +54,14 @@ export type PrStatus = { mergeable?: boolean; }; +export type PrIdentity = { + number: number; + url: string; + title?: string; + sourceBranch?: string; + repo?: string; +}; + /** A review comment on a PR/MR. */ export type PrReviewComment = { id: number; @@ -86,10 +96,16 @@ export interface IssueProvider { reopenIssue(issueId: number): Promise; getMergedMRUrl(issueId: number): Promise; getPrStatus(issueId: number): Promise; - mergePr(issueId: number): Promise; + getLinkedPrs(issueId: number): Promise; + getPrByUrl(prUrl: string): Promise; + getPrByNumber(prNumber: number): Promise; + getPrStatusByUrl(prUrl: string): Promise; + mergePr(issueId: number, opts?: { prUrl?: string; prNumber?: number }): Promise; getPrDiff(issueId: number): Promise; + getPrDiffByUrl(prUrl: string): Promise; /** Get review comments on the PR linked to an issue. */ getPrReviewComments(issueId: number): Promise; + getPrReviewCommentsByUrl(prUrl: string): Promise; /** * Check if work for an issue is already present on the base branch via git history. * Used as a fallback when no PR exists (e.g., work committed directly to main). diff --git a/lib/services/canonical-pr.test.ts b/lib/services/canonical-pr.test.ts new file mode 100644 index 00000000..6af0197b --- /dev/null +++ b/lib/services/canonical-pr.test.ts @@ -0,0 +1,135 @@ +import { afterEach, describe, it } from "node:test"; +import assert from "node:assert"; +import { mkdtemp, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { PrState } from "../providers/provider.js"; +import { TestProvider } from "../testing/test-provider.js"; +import { loadCanonicalPrRecord, recordCanonicalPr, refreshCanonicalPrStatus, resolveCanonicalPrForIssue } from "./canonical-pr.js"; + +const temps: string[] = []; + +async function makeWorkspace(): Promise { + const dir = await mkdtemp(join(tmpdir(), "canonical-pr-test-")); + temps.push(dir); + return dir; +} + +afterEach(async () => { + await Promise.all(temps.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))); +}); + +describe("canonical-pr ledger", () => { + it("records superseded canonical PRs when a replacement is recorded", async () => { + const workspaceDir = await makeWorkspace(); + + await recordCanonicalPr(workspaceDir, "test-project", 244, { + number: 245, + url: "https://example.com/pr/245", + sourceBranch: "issue/244-canonical-pr-ledger", + repo: "owner/repo", + }, PrState.OPEN); + + const replacement = await recordCanonicalPr(workspaceDir, "test-project", 244, { + number: 246, + url: "https://example.com/pr/246", + sourceBranch: "issue/244-canonical-pr-ledger-v2", + repo: "owner/repo", + }, PrState.OPEN); + + assert.equal(replacement.url, "https://example.com/pr/246"); + assert.equal(replacement.supersededPrs.length, 1); + assert.equal(replacement.supersededPrs[0]?.url, "https://example.com/pr/245"); + assert.equal(replacement.supersededPrs[0]?.reason, "replacement"); + }); + + it("refreshes canonical PR status without clobbering supersession history", async () => { + const workspaceDir = await makeWorkspace(); + + await recordCanonicalPr(workspaceDir, "test-project", 244, { + number: 245, + url: "https://example.com/pr/245", + sourceBranch: "issue/244-canonical-pr-ledger", + repo: "owner/repo", + }, PrState.OPEN); + await recordCanonicalPr(workspaceDir, "test-project", 244, { + number: 246, + url: "https://example.com/pr/246", + sourceBranch: "issue/244-canonical-pr-ledger-v2", + repo: "owner/repo", + }, PrState.OPEN); + + const refreshed = await refreshCanonicalPrStatus(workspaceDir, "test-project", 244, { + state: PrState.APPROVED, + url: "https://example.com/pr/246", + number: 246, + sourceBranch: "issue/244-canonical-pr-ledger-v2", + }); + + assert.equal(refreshed?.status, PrState.APPROVED); + assert.equal(refreshed?.supersededPrs.length, 1); + assert.equal(refreshed?.supersededPrs[0]?.url, "https://example.com/pr/245"); + }); + + it("serializes concurrent updates so replacement and refresh do not lose data", async () => { + const workspaceDir = await makeWorkspace(); + + await recordCanonicalPr(workspaceDir, "test-project", 244, { + number: 245, + url: "https://example.com/pr/245", + sourceBranch: "issue/244-a", + repo: "owner/repo", + }, PrState.OPEN); + + await Promise.all([ + refreshCanonicalPrStatus(workspaceDir, "test-project", 244, { + state: PrState.CHANGES_REQUESTED, + url: "https://example.com/pr/245", + number: 245, + sourceBranch: "issue/244-a", + }), + recordCanonicalPr(workspaceDir, "test-project", 244, { + number: 246, + url: "https://example.com/pr/246", + sourceBranch: "issue/244-b", + repo: "owner/repo", + }, PrState.OPEN), + ]); + + const record = await loadCanonicalPrRecord(workspaceDir, "test-project", 244); + assert.ok(record); + assert.equal(record?.url, "https://example.com/pr/246"); + assert.equal(record?.supersededPrs.length, 1); + assert.equal(record?.supersededPrs[0]?.url, "https://example.com/pr/245"); + }); + + it("fails closed when stored canonical PR is no longer linked to the issue", async () => { + const workspaceDir = await makeWorkspace(); + const provider = new TestProvider(); + + await recordCanonicalPr(workspaceDir, "test-project", 244, { + number: 245, + url: "https://example.com/pr/245", + sourceBranch: "issue/244-a", + repo: "owner/repo", + }, PrState.OPEN); + + provider.setLinkedPrs(244, [{ + number: 246, + url: "https://example.com/pr/246", + sourceBranch: "issue/244-b", + repo: "owner/repo", + }]); + provider.prStatuses.set(244, { + state: PrState.OPEN, + url: "https://example.com/pr/246", + number: 246, + sourceBranch: "issue/244-b", + }); + + await assert.rejects( + resolveCanonicalPrForIssue({ workspaceDir, projectSlug: "test-project", issueId: 244, provider }), + /stored PR .* is not in the issue's current linked PR set/, + ); + }); +}); diff --git a/lib/services/canonical-pr.ts b/lib/services/canonical-pr.ts new file mode 100644 index 00000000..22d7e1e5 --- /dev/null +++ b/lib/services/canonical-pr.ts @@ -0,0 +1,271 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { DATA_DIR } from "../setup/migrate-layout.js"; +import type { IssueProvider, PrIdentity, PrStatus } from "../providers/provider.js"; + +export type CanonicalPrRecord = { + issueId: number; + number: number; + url: string; + sourceBranch?: string; + repo?: string; + status: PrStatus["state"]; + updatedAt: string; + supersededPrs: Array<{ + number: number; + url: string; + sourceBranch?: string; + repo?: string; + supersededAt: string; + reason: "replacement" | "reconciliation"; + }>; +}; + +type CanonicalPrStore = { + issues: Record; +}; + +const LOCK_RETRY_MS = 50; +const LOCK_TIMEOUT_MS = 5_000; + +type CanonicalPrMutationResult = { + store: CanonicalPrStore; + result: T; +}; + +function storePath(workspaceDir: string, projectSlug: string): string { + return path.join(workspaceDir, DATA_DIR, "pr-ledger", `${projectSlug}.json`); +} + +function lockPath(workspaceDir: string, projectSlug: string): string { + return `${storePath(workspaceDir, projectSlug)}.lock`; +} + +async function readStore(workspaceDir: string, projectSlug: string): Promise { + const filePath = storePath(workspaceDir, projectSlug); + try { + const raw = await fs.readFile(filePath, "utf-8"); + const parsed = JSON.parse(raw) as Partial; + return { issues: parsed.issues ?? {} }; + } catch { + return { issues: {} }; + } +} + +async function writeStore(workspaceDir: string, projectSlug: string, store: CanonicalPrStore): Promise { + const filePath = storePath(workspaceDir, projectSlug); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + const tmpPath = `${filePath}.tmp`; + await fs.writeFile(tmpPath, JSON.stringify(store, null, 2) + "\n", "utf-8"); + await fs.rename(tmpPath, filePath); +} + +async function sleep(ms: number): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function acquireStoreLock(workspaceDir: string, projectSlug: string): Promise<() => Promise> { + const filePath = storePath(workspaceDir, projectSlug); + const dir = path.dirname(filePath); + const lockDir = lockPath(workspaceDir, projectSlug); + const deadline = Date.now() + LOCK_TIMEOUT_MS; + + await fs.mkdir(dir, { recursive: true }); + + while (true) { + try { + await fs.mkdir(lockDir); + await fs.writeFile(path.join(lockDir, "owner"), JSON.stringify({ pid: process.pid, acquiredAt: new Date().toISOString() }) + "\n", "utf-8"); + return async () => { + await fs.rm(lockDir, { recursive: true, force: true }); + }; + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code !== "EEXIST") throw err; + if (Date.now() >= deadline) { + throw new Error(`Timed out acquiring canonical PR ledger lock for project ${projectSlug}.`); + } + await sleep(LOCK_RETRY_MS); + } + } +} + +async function mutateStore( + workspaceDir: string, + projectSlug: string, + mutate: (store: CanonicalPrStore) => CanonicalPrMutationResult, +): Promise { + const release = await acquireStoreLock(workspaceDir, projectSlug); + try { + const store = await readStore(workspaceDir, projectSlug); + const { store: nextStore, result } = mutate(store); + await writeStore(workspaceDir, projectSlug, nextStore); + return result; + } finally { + await release(); + } +} + +export async function loadCanonicalPrRecord( + workspaceDir: string, + projectSlug: string, + issueId: number, +): Promise { + const store = await readStore(workspaceDir, projectSlug); + return store.issues[String(issueId)] ?? null; +} + +export async function saveCanonicalPrRecord( + workspaceDir: string, + projectSlug: string, + record: CanonicalPrRecord, +): Promise { + await mutateStore(workspaceDir, projectSlug, (store) => { + store.issues[String(record.issueId)] = record; + return { store, result: undefined }; + }); +} + +function samePr(a: { url?: string; number?: number }, b: { url?: string; number?: number }): boolean { + return (!!a.url && !!b.url && a.url === b.url) || (!!a.number && !!b.number && a.number === b.number); +} + +function toRecord(issueId: number, pr: PrIdentity, status: PrStatus["state"], previous?: CanonicalPrRecord): CanonicalPrRecord { + const now = new Date().toISOString(); + const supersededPrs = [...(previous?.supersededPrs ?? [])]; + if (previous && !samePr(previous, pr)) { + supersededPrs.push({ + number: previous.number, + url: previous.url, + sourceBranch: previous.sourceBranch, + repo: previous.repo, + supersededAt: now, + reason: "replacement", + }); + } + return { + issueId, + number: pr.number, + url: pr.url, + sourceBranch: pr.sourceBranch, + repo: pr.repo, + status, + updatedAt: now, + supersededPrs, + }; +} + +export async function recordCanonicalPr( + workspaceDir: string, + projectSlug: string, + issueId: number, + pr: PrIdentity, + status: PrStatus["state"], +): Promise { + return mutateStore(workspaceDir, projectSlug, (store) => { + const previous = store.issues[String(issueId)]; + const next = toRecord(issueId, pr, status, previous); + store.issues[String(issueId)] = next; + return { store, result: next }; + }); +} + +export async function refreshCanonicalPrStatus( + workspaceDir: string, + projectSlug: string, + issueId: number, + status: PrStatus, +): Promise { + return mutateStore(workspaceDir, projectSlug, (store) => { + const existing = store.issues[String(issueId)]; + if (!existing) return { store, result: null }; + const updated: CanonicalPrRecord = { + ...existing, + status: status.state, + updatedAt: new Date().toISOString(), + sourceBranch: status.sourceBranch ?? existing.sourceBranch, + number: status.number ?? existing.number, + }; + store.issues[String(issueId)] = updated; + return { store, result: updated }; + }); +} + +export async function resolveCanonicalPrForIssue(opts: { + workspaceDir: string; + projectSlug: string; + issueId: number; + provider: IssueProvider; + allowBackfill?: boolean; +}): Promise { + const { workspaceDir, projectSlug, issueId, provider, allowBackfill = true } = opts; + const existing = await loadCanonicalPrRecord(workspaceDir, projectSlug, issueId); + const linked = await provider.getLinkedPrs(issueId); + + if (existing) { + if (linked.length === 0) { + throw new Error(`Canonical PR routing integrity failure for issue #${issueId}: stored PR ${existing.url} is no longer linked to the issue.`); + } + const stillLinked = linked.some((pr) => samePr(pr, existing)); + if (!stillLinked) { + const linkedSummary = linked.map((pr) => pr.url).join(", "); + throw new Error(`Canonical PR routing integrity failure for issue #${issueId}: stored PR ${existing.url} is not in the issue's current linked PR set (${linkedSummary}).`); + } + const status = await provider.getPrStatusByUrl(existing.url); + if (!status) { + throw new Error(`Canonical PR routing integrity failure for issue #${issueId}: stored PR ${existing.url} no longer resolves.`); + } + return (await refreshCanonicalPrStatus(workspaceDir, projectSlug, issueId, status)) ?? existing; + } + + if (linked.length !== 1 || !allowBackfill) { + const summary = linked.length === 0 ? "no linked PR found" : `${linked.length} linked PRs found`; + throw new Error(`Canonical PR routing integrity failure for issue #${issueId}: ${summary}. Operator must set or recreate a single canonical PR.`); + } + + const status = await provider.getPrStatusByUrl(linked[0]!.url); + if (!status) { + throw new Error(`Canonical PR routing integrity failure for issue #${issueId}: linked PR ${linked[0]!.url} could not be resolved.`); + } + return recordCanonicalPr(workspaceDir, projectSlug, issueId, linked[0]!, status.state); +} + +export async function resolveDeveloperCanonicalPr(opts: { + workspaceDir: string; + projectSlug: string; + issueId: number; + provider: IssueProvider; + explicitPrUrl?: string; +}): Promise { + const { workspaceDir, projectSlug, issueId, provider, explicitPrUrl } = opts; + const linked = await provider.getLinkedPrs(issueId); + if (linked.length === 0) { + throw new Error(`Cannot mark work_finish(done) without an open PR. No PR is linked to issue #${issueId}.`); + } + + let chosen: PrIdentity | null = null; + if (explicitPrUrl) { + chosen = linked.find((pr) => pr.url === explicitPrUrl) ?? await provider.getPrByUrl(explicitPrUrl); + if (!chosen || !linked.some((pr) => samePr(pr, chosen!))) { + throw new Error(`Canonical PR routing is ambiguous for issue #${issueId}: explicit prUrl ${explicitPrUrl} is not one of the issue-linked PRs.`); + } + } else if (linked.length === 1) { + chosen = linked[0]!; + } else { + throw new Error(`Canonical PR routing is ambiguous for issue #${issueId}: ${linked.length} linked PRs exist and no explicit prUrl was provided.`); + } + + const existing = await loadCanonicalPrRecord(workspaceDir, projectSlug, issueId); + if (existing && !samePr(existing, chosen)) { + if (!explicitPrUrl) { + throw new Error(`Canonical PR routing is ambiguous for issue #${issueId}: existing canonical PR ${existing.url} differs from the newly discovered PR ${chosen.url}. Provide prUrl explicitly to supersede it.`); + } + } + + const status = await provider.getPrStatusByUrl(chosen.url); + if (!status) { + throw new Error(`Canonical PR routing integrity failure for issue #${issueId}: ${chosen.url} could not be resolved.`); + } + + return recordCanonicalPr(workspaceDir, projectSlug, issueId, chosen, status.state); +} diff --git a/lib/services/heartbeat/review.ts b/lib/services/heartbeat/review.ts index dc53780d..578e1543 100644 --- a/lib/services/heartbeat/review.ts +++ b/lib/services/heartbeat/review.ts @@ -20,6 +20,15 @@ import type { RunCommand } from "../../context.js"; import { log as auditLog } from "../../audit.js"; import { recordLoopDiagnostic } from "../loop-diagnostics.js"; import { recordAndApplyInterventionEvent } from "../../orchestrator-intervention/engine.js"; +import { loadCanonicalPrRecord, resolveCanonicalPrForIssue, refreshCanonicalPrStatus } from "../canonical-pr.js"; +import { buildRefiningHoldComment } from "../pipeline.js"; + +function findRefiningLabel(workflow: WorkflowConfig): string | null { + for (const state of Object.values(workflow.states)) { + if (state.label === "Refining") return state.label; + } + return null; +} /** * Scan review-type states and transition issues whose PR check condition is met. @@ -71,7 +80,38 @@ export async function reviewPass(opts: { const isManaged = await provider.issueHasReaction(issue.iid, "eyes"); if (!isManaged) continue; - const status = await provider.getPrStatus(issue.iid); + let canonical; + const projectSlug = project?.slug ?? projectName; + const existingCanonical = await loadCanonicalPrRecord(workspaceDir, projectSlug, issue.iid); + const linkedPrs = await provider.getLinkedPrs(issue.iid); + if (!existingCanonical && linkedPrs.length === 0) { + continue; + } + try { + canonical = await resolveCanonicalPrForIssue({ workspaceDir, projectSlug, issueId: issue.iid, provider }); + } catch (err) { + const refiningLabel = findRefiningLabel(workflow); + if (refiningLabel && refiningLabel !== state.label) { + await provider.addComment(issue.iid, buildRefiningHoldComment({ + role: "reviewer", + result: "blocked", + from: state.label, + to: refiningLabel, + summary: `Canonical PR routing integrity failed during review heartbeat: ${(err as Error).message ?? String(err)}`, + source: "system", + })); + await provider.transitionLabel(issue.iid, state.label, refiningLabel); + transitions++; + } + await auditLog(workspaceDir, "review_transition", { + project: projectName, issueId: issue.iid, from: state.label, to: refiningLabel ?? state.label, + reason: "canonical_pr_missing_or_ambiguous", error: (err as Error).message ?? String(err), + }); + continue; + } + const status = await provider.getPrStatusByUrl(canonical.url); + if (!status) continue; + await refreshCanonicalPrStatus(workspaceDir, projectSlug, issue.iid, status).catch(() => {}); // Fallback: no PR found, but work may have been committed directly to base branch. // Check git history for commits mentioning this issue number. @@ -274,7 +314,11 @@ export async function reviewPass(opts: { break; } try { - await provider.mergePr(issue.iid); + const latestCanonical = await resolveCanonicalPrForIssue({ workspaceDir, projectSlug, issueId: issue.iid, provider }); + if (latestCanonical.url !== canonical.url) { + throw new Error(`Canonical PR changed after approval for issue #${issue.iid}: approved ${canonical.url}, current ${latestCanonical.url}`); + } + await provider.mergePr(issue.iid, { prUrl: canonical.url, prNumber: canonical.number }); onMerge?.(issue.iid, status.url, status.title, status.sourceBranch); } catch (err) { // Merge failed → fire MERGE_FAILED transition (developer fixes conflicts) diff --git a/lib/services/pipeline.e2e.test.ts b/lib/services/pipeline.e2e.test.ts index de592337..c93340bf 100644 --- a/lib/services/pipeline.e2e.test.ts +++ b/lib/services/pipeline.e2e.test.ts @@ -17,6 +17,8 @@ import { projectTick } from "./tick.js"; import { reviewPass } from "./heartbeat/review.js"; import { DEFAULT_WORKFLOW, ReviewPolicy, type WorkflowConfig } from "../workflow/index.js"; import { readProjects, getRoleWorker, getProject, countActiveSlots } from "../projects/index.js"; +import { PrState } from "../providers/provider.js"; +import { recordCanonicalPr } from "./canonical-pr.js"; // --------------------------------------------------------------------------- // Test suite @@ -850,6 +852,13 @@ describe("E2E pipeline", () => { runCommand: h.runCommand, }); + h.provider.setPrStatus(100, { + state: "open", + url: "https://example.com/pr/100", + number: 100, + sourceBranch: "feature/100-dashboard", + }); + // 3. Developer done → To Review await executeCompletion({ workspaceDir: h.workspaceDir, @@ -1022,6 +1031,13 @@ describe("E2E pipeline", () => { runCommand: h.runCommand, }); + h.provider.setPrStatus(300, { + state: "open", + url: "https://example.com/pr/300", + number: 300, + sourceBranch: "feature/300-payment-flow", + }); + // 2. Developer done → To Review await executeCompletion({ workspaceDir: h.workspaceDir, @@ -1177,6 +1193,19 @@ describe("E2E pipeline", () => { it("reviewPolicy: agent should dispatch reviewer", async () => { h = await createTestHarness(); h.provider.seedIssue({ iid: 81, title: "Needs review", labels: ["To Review"] }); + h.provider.setPrStatus(81, { + state: PrState.OPEN, + url: "https://example.com/pr/81", + number: 81, + sourceBranch: "issue/81-needs-review", + }); + h.provider.prDiffs.set(81, "diff --git a/file.ts b/file.ts"); + await recordCanonicalPr(h.workspaceDir, h.project.slug, 81, { + number: 81, + url: "https://example.com/pr/81", + title: "Needs review", + sourceBranch: "issue/81-needs-review", + }, PrState.OPEN); const result = await projectTick({ workspaceDir: h.workspaceDir, @@ -1294,6 +1323,18 @@ describe("E2E pipeline", () => { h = await createTestHarness(); // Issue already has a developer:junior label from a previous dispatch h.provider.seedIssue({ iid: 401, title: "Re-dispatch", labels: ["To Improve", "developer:junior"] }); + h.provider.setPrStatus(401, { + state: PrState.CHANGES_REQUESTED, + url: "https://example.com/pr/401", + number: 401, + sourceBranch: "issue/401-redispatch", + }); + await recordCanonicalPr(h.workspaceDir, h.project.slug, 401, { + number: 401, + url: "https://example.com/pr/401", + title: "Re-dispatch", + sourceBranch: "issue/401-redispatch", + }, PrState.CHANGES_REQUESTED); await dispatchTask({ workspaceDir: h.workspaceDir, @@ -1339,6 +1380,19 @@ describe("E2E pipeline", () => { it("projectTick should dispatch reviewer when review:agent label present", async () => { h = await createTestHarness(); h.provider.seedIssue({ iid: 403, title: "Junior fix", labels: ["To Review", "developer:junior", "review:agent"] }); + h.provider.setPrStatus(403, { + state: PrState.OPEN, + url: "https://example.com/pr/403", + number: 403, + sourceBranch: "issue/403-junior-fix", + }); + h.provider.prDiffs.set(403, "diff --git a/file.ts b/file.ts"); + await recordCanonicalPr(h.workspaceDir, h.project.slug, 403, { + number: 403, + url: "https://example.com/pr/403", + title: "Junior fix", + sourceBranch: "issue/403-junior-fix", + }, PrState.OPEN); const result = await projectTick({ workspaceDir: h.workspaceDir, diff --git a/lib/services/pipeline.ts b/lib/services/pipeline.ts index 83a52248..a82c94ed 100644 --- a/lib/services/pipeline.ts +++ b/lib/services/pipeline.ts @@ -29,6 +29,7 @@ import { type WorkflowConfig, } from "../workflow/index.js"; import type { Channel } from "../projects/index.js"; +import { resolveCanonicalPrForIssue } from "./canonical-pr.js"; export type { CompletionRule }; @@ -176,11 +177,11 @@ export async function executeCompletion(opts: { break; case Action.DETECT_PR: if (!prUrl) { try { - // Try open PR first (developer just finished — MR is still open), fall back to merged - const prStatus = await provider.getPrStatus(issueId); - prUrl = prStatus.url ?? await provider.getMergedMRUrl(issueId) ?? undefined; - prTitle = prStatus.title; - sourceBranch = prStatus.sourceBranch; + const canonical = await resolveCanonicalPrForIssue({ workspaceDir, projectSlug, issueId, provider }); + const prStatus = await provider.getPrStatusByUrl(canonical.url); + prUrl = canonical.url; + prTitle = prStatus?.title ?? canonical.url; + sourceBranch = prStatus?.sourceBranch ?? canonical.sourceBranch; } catch (err) { auditLog(workspaceDir, "pipeline_warning", { step: "detectPr", issue: issueId, role, error: (err as Error).message ?? String(err) }).catch(() => {}); } } @@ -188,18 +189,18 @@ export async function executeCompletion(opts: { case Action.MERGE_PR: try { // Grab PR metadata before merging (the MR is still open at this point) + const canonical = await resolveCanonicalPrForIssue({ workspaceDir, projectSlug, issueId, provider }); + const prStatus = await provider.getPrStatusByUrl(canonical.url); + prUrl = canonical.url; if (!prTitle) { - try { - const prStatus = await provider.getPrStatus(issueId); - prUrl = prUrl ?? prStatus.url ?? undefined; - prTitle = prStatus.title; - sourceBranch = prStatus.sourceBranch; - } catch { /* best-effort */ } + prTitle = prStatus?.title; + sourceBranch = prStatus?.sourceBranch ?? canonical.sourceBranch; } - await provider.mergePr(issueId); + await provider.mergePr(issueId, { prUrl: canonical.url, prNumber: canonical.number }); mergedPr = true; } catch (err) { auditLog(workspaceDir, "pipeline_warning", { step: "mergePr", issue: issueId, role, error: (err as Error).message ?? String(err) }).catch(() => {}); + throw err; } break; } diff --git a/lib/testing/test-provider.ts b/lib/testing/test-provider.ts index 03270edd..87f1ed80 100644 --- a/lib/testing/test-provider.ts +++ b/lib/testing/test-provider.ts @@ -9,6 +9,7 @@ import type { Issue, StateLabel, IssueComment, + PrIdentity, PrStatus, } from "../providers/provider.js"; import { getStateLabels } from "../workflow/index.js"; @@ -66,6 +67,8 @@ export class TestProvider implements IssueProvider { prStatuses = new Map(); /** Merged MR URLs per issue. */ mergedMrUrls = new Map(); + /** Linked PRs per issue. */ + linkedPrs = new Map(); /** Issue IDs where mergePr should fail (simulates merge conflicts). */ mergePrFailures = new Set(); /** PR diffs per issue (for reviewer tests). */ @@ -103,6 +106,13 @@ export class TestProvider implements IssueProvider { /** Set PR status for an issue (used by review pass tests). */ setPrStatus(issueId: number, status: PrStatus): void { this.prStatuses.set(issueId, status); + if (status.url) { + this.linkedPrs.set(issueId, [{ number: status.number ?? issueId, url: status.url, title: status.title, sourceBranch: status.sourceBranch }]); + } + } + + setLinkedPrs(issueId: number, prs: PrIdentity[]): void { + this.linkedPrs.set(issueId, prs); } /** Get calls filtered by method name. */ @@ -124,6 +134,7 @@ export class TestProvider implements IssueProvider { this.labels.clear(); this.prStatuses.clear(); this.mergedMrUrls.clear(); + this.linkedPrs.clear(); this.mergePrFailures.clear(); this.prDiffs.clear(); this.calls = []; @@ -248,7 +259,36 @@ export class TestProvider implements IssueProvider { return this.prStatuses.get(issueId) ?? { state: "closed", url: null }; } - async mergePr(issueId: number): Promise { + async getLinkedPrs(issueId: number): Promise { + return this.linkedPrs.get(issueId) ?? []; + } + + async getPrByUrl(prUrl: string): Promise { + for (const prs of this.linkedPrs.values()) { + const match = prs.find((pr) => pr.url === prUrl); + if (match) return match; + } + return null; + } + + async getPrByNumber(prNumber: number): Promise { + for (const prs of this.linkedPrs.values()) { + const match = prs.find((pr) => pr.number === prNumber); + if (match) return match; + } + return null; + } + + async getPrStatusByUrl(prUrl: string): Promise { + for (const [issueId, status] of this.prStatuses.entries()) { + if (status.url === prUrl) return { ...status, number: status.number ?? (await this.getPrByUrl(prUrl))?.number }; + const linked = this.linkedPrs.get(issueId) ?? []; + if (linked.some((pr) => pr.url === prUrl)) return { ...status, number: status.number ?? linked.find((pr) => pr.url === prUrl)?.number }; + } + return null; + } + + async mergePr(issueId: number, _opts?: { prUrl?: string; prNumber?: number }): Promise { this.calls.push({ method: "mergePr", args: { issueId } }); if (this.mergePrFailures.has(issueId)) { throw new Error(`Merge conflict: cannot merge PR for issue #${issueId}`); @@ -265,10 +305,21 @@ export class TestProvider implements IssueProvider { return this.prDiffs.get(issueId) ?? null; } + async getPrDiffByUrl(prUrl: string): Promise { + for (const [issueId, prs] of this.linkedPrs.entries()) { + if (prs.some((pr) => pr.url === prUrl)) return this.prDiffs.get(issueId) ?? null; + } + return null; + } + async getPrReviewComments(_issueId: number): Promise { return []; } + async getPrReviewCommentsByUrl(_prUrl: string): Promise { + return []; + } + async reactToIssue(_issueId: number, _emoji: string): Promise { // no-op in test provider } diff --git a/lib/tools/worker/work-finish.test.ts b/lib/tools/worker/work-finish.test.ts index dc3bf190..59de7317 100644 --- a/lib/tools/worker/work-finish.test.ts +++ b/lib/tools/worker/work-finish.test.ts @@ -14,6 +14,9 @@ import assert from "node:assert"; import { mkdtemp, writeFile, readFile, rm, mkdir } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; +import { TestProvider } from "../../testing/test-provider.js"; +import { PrState } from "../../providers/provider.js"; +import { validatePrExistsForDeveloper } from "./work-finish.js"; // Helper to create a mock audit log with a merge_conflict transition async function createMockAuditLog(workspaceDir: string, issueId: number, hasMergeConflict: boolean): Promise { @@ -163,6 +166,34 @@ describe("work_finish: PR validation and conflict resolution", () => { }); describe("validatePrExistsForDeveloper: conflict detection", () => { + it("rejects completion when current branch does not match canonical PR branch", async () => { + const provider = new TestProvider(); + provider.setLinkedPrs(244, [{ + number: 245, + url: "https://github.com/test/repo/pull/245", + sourceBranch: "issue/244-canonical-pr-ledger", + repo: "test/repo", + }]); + provider.prStatuses.set(244, { + state: PrState.OPEN, + url: "https://github.com/test/repo/pull/245", + number: 245, + sourceBranch: "issue/244-canonical-pr-ledger", + }); + + await assert.rejects( + validatePrExistsForDeveloper( + 244, + "/tmp/repo", + provider, + async () => ({ stdout: "wrong-branch\n", stderr: "", exitCode: 0, code: 0, signal: null, killed: false, termination: "exit" } as any), + tempDir, + "devclaw", + ), + /current branch wrong-branch does not match canonical PR branch issue\/244-canonical-pr-ledger/, + ); + }); + it("should validate error message format when PR still conflicting", async () => { // Test that our error message matches the expected pattern const errorMessage = diff --git a/lib/tools/worker/work-finish.ts b/lib/tools/worker/work-finish.ts index 8030a921..0154e090 100644 --- a/lib/tools/worker/work-finish.ts +++ b/lib/tools/worker/work-finish.ts @@ -14,6 +14,7 @@ import type { ToolContext } from "../../types.js"; import type { PluginContext, RunCommand } from "../../context.js"; import { getRoleWorker, resolveRepoPath, findSlotByIssue } from "../../projects/index.js"; import { executeCompletion, getRule } from "../../services/pipeline.js"; +import { resolveDeveloperCanonicalPr } from "../../services/canonical-pr.js"; import { log as auditLog } from "../../audit.js"; import { DATA_DIR } from "../../setup/migrate-layout.js"; import { requireWorkspaceDir, resolveChannelId, resolveProject, resolveProvider } from "../helpers.js"; @@ -79,27 +80,47 @@ async function isConflictResolutionCycle( * - We check `url === null` rather than the state field to be explicit: * a null URL unambiguously means "nothing found", regardless of state label. */ -async function validatePrExistsForDeveloper( +export async function validatePrExistsForDeveloper( issueId: number, repoPath: string, provider: Awaited>["provider"], runCommand: RunCommand, workspaceDir: string, projectSlug: string, + prUrl?: string, ): Promise { try { - const prStatus = await provider.getPrStatus(issueId); + let currentBranch = ""; + try { + currentBranch = await getCurrentBranch(repoPath, runCommand); + } catch { + // Best-effort only. Detached HEAD or provider-only environments can leave this blank. + } + + const canonicalPr = await resolveDeveloperCanonicalPr({ + workspaceDir, + projectSlug, + issueId, + provider, + explicitPrUrl: prUrl, + }); + const prStatus = await provider.getPrStatusByUrl(canonicalPr.url); + + if (!prStatus) { + throw new Error(`Canonical PR routing integrity failure for issue #${issueId}: ${canonicalPr.url} could not be resolved.`); + } + + const canonicalBranch = prStatus.sourceBranch ?? canonicalPr.sourceBranch; + if (currentBranch && canonicalBranch && currentBranch !== canonicalBranch) { + throw new Error( + `Canonical PR routing integrity failure for issue #${issueId}: current branch ${currentBranch} does not match canonical PR branch ${canonicalBranch} (${canonicalPr.url}).`, + ); + } // url is null when getPrStatus found no open or merged PR for this issue. // This covers both "no PR ever created" and "PR was closed without merging". if (!prStatus.url) { - // Get current branch for a helpful gh pr create example - let branchName = "current-branch"; - try { - branchName = await getCurrentBranch(repoPath, runCommand); - } catch { - // Fall back to generic placeholder - } + const branchName = currentBranch || "current-branch"; throw new Error( `Cannot mark work_finish(done) without an open PR.\n\n` + @@ -168,9 +189,13 @@ async function validatePrExistsForDeveloper( }); } } catch (err) { - // Re-throw our own validation errors; swallow provider/network errors. - // Swallowing keeps work_finish unblocked when the API is unreachable. - if (err instanceof Error && (err.message.startsWith("Cannot mark work_finish(done)") || err.message.startsWith("Cannot complete work_finish(done)"))) { + // Re-throw explicit validation and routing-integrity failures. + // Swallow only transient provider/network errors so unrelated outages do not block completion. + if (err instanceof Error && ( + err.message.startsWith("Cannot mark work_finish(done)") || + err.message.startsWith("Cannot complete work_finish(done)") || + err.message.startsWith("Canonical PR routing") + )) { throw err; } console.warn(`PR validation warning for issue #${issueId}:`, err); @@ -294,7 +319,7 @@ export function createWorkFinishTool(ctx: PluginContext) { // For developers marking work as done, validate that a PR exists if (role === "developer" && result === "done") { - await validatePrExistsForDeveloper(issueId, repoPath, provider, ctx.runCommand, workspaceDir, project.slug); + await validatePrExistsForDeveloper(issueId, repoPath, provider, ctx.runCommand, workspaceDir, project.slug, prUrl); } const completion = await executeCompletion({ From 99d4a232b6a85bd74165c07d2391b63080a4a1a5 Mon Sep 17 00:00:00 2001 From: fujiwaranosai850 Date: Sun, 31 May 2026 05:30:57 +0000 Subject: [PATCH 27/30] fix: fail closed on canonical review routing gaps --- lib/dispatch/pr-context.test.ts | 27 ++++++++++++++++++++++ lib/dispatch/pr-context.ts | 10 ++++++--- lib/services/heartbeat/review.ts | 28 +++++++++++++++++++++-- lib/services/pipeline.e2e.test.ts | 37 +++++++++++++++++++++++++++++++ 4 files changed, 97 insertions(+), 5 deletions(-) diff --git a/lib/dispatch/pr-context.test.ts b/lib/dispatch/pr-context.test.ts index b3315bde..7cb006b4 100644 --- a/lib/dispatch/pr-context.test.ts +++ b/lib/dispatch/pr-context.test.ts @@ -185,6 +185,33 @@ describe("canonical PR dispatch routing", () => { } }); + it("fails closed when canonical diff lookup cannot load URL-scoped context", async () => { + const workspaceDir = await mkdtemp(path.join(os.tmpdir(), "devclaw-pr-context-diff-")); + try { + const provider = new TestProvider(); + provider.seedIssue({ iid: 79, title: "Needs diff", labels: ["To Review"] }); + provider.setPrStatus(79, { + state: PrState.OPEN, + url: "https://example.com/pr/79", + number: 79, + sourceBranch: "issue/79-needs-diff", + }); + await recordCanonicalPr(workspaceDir, "test-project", 79, { + number: 79, + url: "https://example.com/pr/79", + title: "Needs diff", + sourceBranch: "issue/79-needs-diff", + }, PrState.OPEN); + + await assert.rejects( + () => fetchPrContext(provider, 79, { workspaceDir, projectSlug: "test-project" }), + /has no URL-scoped diff context/, + ); + } finally { + await rm(workspaceDir, { recursive: true, force: true }); + } + }); + it("preserves canonical feedback routing even when comments are empty", async () => { const workspaceDir = await mkdtemp(path.join(os.tmpdir(), "devclaw-pr-feedback-")); try { diff --git a/lib/dispatch/pr-context.ts b/lib/dispatch/pr-context.ts index 1bf4b449..45941a18 100644 --- a/lib/dispatch/pr-context.ts +++ b/lib/dispatch/pr-context.ts @@ -103,10 +103,14 @@ export async function fetchPrContext( if (!prStatus?.url) return undefined; const diff = canonicalRouting - ? await provider.getPrDiffByUrl(prStatus.url) ?? undefined - : await provider.getPrDiff(issueId) ?? undefined; + ? await provider.getPrDiffByUrl(prStatus.url) + : await provider.getPrDiff(issueId); - return { url: prStatus.url, diff }; + if (canonicalUrl && diff == null) { + throw new Error(`Canonical PR routing integrity failure for issue #${issueId}: stored PR ${canonicalUrl} has no URL-scoped diff context.`); + } + + return { url: prStatus.url, diff: diff ?? undefined }; } // --------------------------------------------------------------------------- diff --git a/lib/services/heartbeat/review.ts b/lib/services/heartbeat/review.ts index 578e1543..29dc7f64 100644 --- a/lib/services/heartbeat/review.ts +++ b/lib/services/heartbeat/review.ts @@ -109,8 +109,32 @@ export async function reviewPass(opts: { }); continue; } - const status = await provider.getPrStatusByUrl(canonical.url); - if (!status) continue; + let status = await provider.getPrStatusByUrl(canonical.url); + if (!status) { + const message = `Canonical PR routing integrity failure for issue #${issue.iid}: stored PR ${canonical.url} no longer resolves during review heartbeat.`; + const refiningLabel = findRefiningLabel(workflow); + if (refiningLabel && refiningLabel !== state.label) { + await provider.addComment(issue.iid, buildRefiningHoldComment({ + role: "reviewer", + result: "blocked", + from: state.label, + to: refiningLabel, + summary: message, + source: "system", + })); + await provider.transitionLabel(issue.iid, state.label, refiningLabel); + transitions++; + } + await auditLog(workspaceDir, "review_transition", { + project: projectName, + issueId: issue.iid, + from: state.label, + to: refiningLabel ?? state.label, + reason: "canonical_pr_status_missing", + error: message, + }); + continue; + } await refreshCanonicalPrStatus(workspaceDir, projectSlug, issue.iid, status).catch(() => {}); // Fallback: no PR found, but work may have been committed directly to base branch. diff --git a/lib/services/pipeline.e2e.test.ts b/lib/services/pipeline.e2e.test.ts index c93340bf..7b302545 100644 --- a/lib/services/pipeline.e2e.test.ts +++ b/lib/services/pipeline.e2e.test.ts @@ -822,6 +822,43 @@ describe("E2E pipeline", () => { const issue = await h.provider.getIssue(82); assert.ok(issue.labels.includes("To Review"), "Should remain in To Review"); }); + + it("should move review issues to Refining when canonical PR re-resolution fails", async () => { + h.provider.seedIssue({ iid: 83, title: "Broken canonical PR", labels: ["To Review", "review:human"] }); + h.provider.setLinkedPrs(83, [{ + number: 83, + url: "https://example.com/pr/83", + title: "Broken canonical PR", + sourceBranch: "issue/83-broken-canonical-pr", + }]); + await recordCanonicalPr(h.workspaceDir, h.project.slug, 83, { + number: 83, + url: "https://example.com/pr/83", + title: "Broken canonical PR", + sourceBranch: "issue/83-broken-canonical-pr", + }, PrState.OPEN); + + const transitions = await reviewPass({ + workspaceDir: h.workspaceDir, + projectName: h.project.name, + project: h.project, + workflow: DEFAULT_WORKFLOW, + provider: h.provider, + repoPath: "/tmp/test-repo", + runCommand: h.runCommand, + }); + + assert.strictEqual(transitions, 1, "Should surface a visible integrity hold"); + + const issue = await h.provider.getIssue(83); + assert.ok(issue.labels.includes("Refining"), `Labels: ${issue.labels}`); + assert.ok(!issue.labels.includes("To Review"), "Should leave To Review after integrity failure"); + + const comments = h.provider.comments.get(83) ?? []; + assert.strictEqual(comments.length, 1, "Should leave a hold comment for the operator"); + assert.match(comments[0]!.body, /Canonical PR routing integrity failed during review heartbeat/); + assert.match(comments[0]!.body, /stored PR https:\/\/example\.com\/pr\/83 no longer resolves/); + }); }); // ========================================================================= From 766fe8a2a1b70bfc8a57f237789d6851c4ee27a5 Mon Sep 17 00:00:00 2001 From: fujiwaranosai850 Date: Sun, 31 May 2026 06:14:47 +0000 Subject: [PATCH 28/30] fix: harden canonical PR review context --- lib/dispatch/pr-context.test.ts | 49 +++++++++++++++++++++++++++++++++ lib/dispatch/pr-context.ts | 21 ++++++++++---- 2 files changed, 65 insertions(+), 5 deletions(-) diff --git a/lib/dispatch/pr-context.test.ts b/lib/dispatch/pr-context.test.ts index 7cb006b4..524e23bb 100644 --- a/lib/dispatch/pr-context.test.ts +++ b/lib/dispatch/pr-context.test.ts @@ -212,6 +212,36 @@ describe("canonical PR dispatch routing", () => { } }); + it("returns authoritative canonical PR context only with a loaded diff", async () => { + const workspaceDir = await mkdtemp(path.join(os.tmpdir(), "devclaw-pr-context-success-")); + try { + const provider = new TestProvider(); + provider.seedIssue({ iid: 76, title: "Canonical review", labels: ["To Review"] }); + provider.setPrStatus(76, { + state: PrState.OPEN, + url: "https://example.com/pr/76", + number: 76, + sourceBranch: "issue/76-canonical-review", + }); + provider.prDiffs.set(76, "diff --git a/a.ts b/a.ts"); + await recordCanonicalPr(workspaceDir, "test-project", 76, { + number: 76, + url: "https://example.com/pr/76", + title: "Canonical review", + sourceBranch: "issue/76-canonical-review", + }, PrState.OPEN); + + const prContext = await fetchPrContext(provider, 76, { workspaceDir, projectSlug: "test-project" }); + assert.deepStrictEqual(prContext, { + url: "https://example.com/pr/76", + diff: "diff --git a/a.ts b/a.ts", + canonical: true, + }); + } finally { + await rm(workspaceDir, { recursive: true, force: true }); + } + }); + it("preserves canonical feedback routing even when comments are empty", async () => { const workspaceDir = await mkdtemp(path.join(os.tmpdir(), "devclaw-pr-feedback-")); try { @@ -239,4 +269,23 @@ describe("canonical PR dispatch routing", () => { await rm(workspaceDir, { recursive: true, force: true }); } }); + + it("keeps legacy issue-scoped review context non-canonical", async () => { + const provider = new TestProvider(); + provider.seedIssue({ iid: 80, title: "Legacy review", labels: ["To Review"] }); + provider.setPrStatus(80, { + state: PrState.OPEN, + url: "https://example.com/pr/80", + number: 80, + sourceBranch: "issue/80-legacy-review", + }); + provider.prDiffs.set(80, "diff --git a/legacy.ts b/legacy.ts"); + + const prContext = await fetchPrContext(provider, 80); + assert.deepStrictEqual(prContext, { + url: "https://example.com/pr/80", + diff: "diff --git a/legacy.ts b/legacy.ts", + canonical: false, + }); + }); }); diff --git a/lib/dispatch/pr-context.ts b/lib/dispatch/pr-context.ts index 45941a18..f1a709fe 100644 --- a/lib/dispatch/pr-context.ts +++ b/lib/dispatch/pr-context.ts @@ -22,10 +22,17 @@ export type PrFeedback = { comments: Array<{ id: number; author: string; body: string; state: string; path?: string; line?: number }>; }; -export type PrContext = { - url: string; - diff?: string; -}; +export type PrContext = + | { + url: string; + diff: string; + canonical: true; + } + | { + url: string; + diff?: string; + canonical: false; + }; // --------------------------------------------------------------------------- // Fetching @@ -110,7 +117,11 @@ export async function fetchPrContext( throw new Error(`Canonical PR routing integrity failure for issue #${issueId}: stored PR ${canonicalUrl} has no URL-scoped diff context.`); } - return { url: prStatus.url, diff: diff ?? undefined }; + if (canonicalUrl) { + return { url: prStatus.url, diff, canonical: true }; + } + + return { url: prStatus.url, diff: diff ?? undefined, canonical: false }; } // --------------------------------------------------------------------------- From f437947ea559dbd494873006e534e7bd0a84a5b7 Mon Sep 17 00:00:00 2001 From: fujiwaranosai850 Date: Sun, 31 May 2026 06:31:10 +0000 Subject: [PATCH 29/30] fix: fail closed on canonical review context --- lib/dispatch/pr-context.test.ts | 36 +++++++++++++++++++++++++++++++ lib/dispatch/pr-context.ts | 9 ++++---- lib/services/pipeline.e2e.test.ts | 9 ++++++-- 3 files changed, 47 insertions(+), 7 deletions(-) diff --git a/lib/dispatch/pr-context.test.ts b/lib/dispatch/pr-context.test.ts index 524e23bb..60bc243c 100644 --- a/lib/dispatch/pr-context.test.ts +++ b/lib/dispatch/pr-context.test.ts @@ -185,6 +185,42 @@ describe("canonical PR dispatch routing", () => { } }); + it("returns an explicitly canonical PR context when authoritative diff loads", async () => { + const workspaceDir = await mkdtemp(path.join(os.tmpdir(), "devclaw-pr-context-canonical-")); + try { + const provider = new TestProvider(); + provider.seedIssue({ iid: 76, title: "Canonical diff", labels: ["To Review"] }); + provider.setPrStatus(76, { + state: PrState.OPEN, + url: "https://example.com/pr/76", + number: 76, + sourceBranch: "issue/76-canonical-diff", + }); + provider.setLinkedPrs(76, [{ + number: 76, + url: "https://example.com/pr/76", + title: "Canonical diff", + sourceBranch: "issue/76-canonical-diff", + }]); + provider.prDiffs.set(76, "diff --git a/file.ts b/file.ts"); + await recordCanonicalPr(workspaceDir, "test-project", 76, { + number: 76, + url: "https://example.com/pr/76", + title: "Canonical diff", + sourceBranch: "issue/76-canonical-diff", + }, PrState.OPEN); + + const context = await fetchPrContext(provider, 76, { workspaceDir, projectSlug: "test-project" }); + assert.deepStrictEqual(context, { + url: "https://example.com/pr/76", + diff: "diff --git a/file.ts b/file.ts", + canonical: true, + }); + } finally { + await rm(workspaceDir, { recursive: true, force: true }); + } + }); + it("fails closed when canonical diff lookup cannot load URL-scoped context", async () => { const workspaceDir = await mkdtemp(path.join(os.tmpdir(), "devclaw-pr-context-diff-")); try { diff --git a/lib/dispatch/pr-context.ts b/lib/dispatch/pr-context.ts index f1a709fe..32a4b8a9 100644 --- a/lib/dispatch/pr-context.ts +++ b/lib/dispatch/pr-context.ts @@ -113,11 +113,10 @@ export async function fetchPrContext( ? await provider.getPrDiffByUrl(prStatus.url) : await provider.getPrDiff(issueId); - if (canonicalUrl && diff == null) { - throw new Error(`Canonical PR routing integrity failure for issue #${issueId}: stored PR ${canonicalUrl} has no URL-scoped diff context.`); - } - - if (canonicalUrl) { + if (canonicalRouting) { + if (diff == null) { + throw new Error(`Canonical PR routing integrity failure for issue #${issueId}: stored PR ${canonicalUrl} has no URL-scoped diff context.`); + } return { url: prStatus.url, diff, canonical: true }; } diff --git a/lib/services/pipeline.e2e.test.ts b/lib/services/pipeline.e2e.test.ts index 7b302545..f249663a 100644 --- a/lib/services/pipeline.e2e.test.ts +++ b/lib/services/pipeline.e2e.test.ts @@ -728,7 +728,7 @@ describe("E2E pipeline", () => { assert.ok(issue.labels.includes("To Test"), `Labels: ${issue.labels}`); }); - it("should transition To Review → To Improve when PR is closed without merging (url non-null)", async () => { + it("should transition To Review → the configured PR_CLOSED target when PR is closed without merging (url non-null)", async () => { // After #315: PrState.CLOSED + url non-null = PR was explicitly closed without merging h.provider.seedIssue({ iid: 80, title: "Closed PR feature", labels: ["To Review", "review:human"] }); h.provider.setPrStatus(80, { state: "closed", url: "https://example.com/pr/80" }); @@ -752,7 +752,12 @@ describe("E2E pipeline", () => { assert.strictEqual(transitions, 1, "Should have made 1 transition"); const issue = await h.provider.getIssue(80); - assert.ok(issue.labels.includes("To Improve"), `Labels: ${issue.labels}`); + const closedTargetKey = typeof DEFAULT_WORKFLOW.states.toReview.on.PR_CLOSED === "string" + ? DEFAULT_WORKFLOW.states.toReview.on.PR_CLOSED + : DEFAULT_WORKFLOW.states.toReview.on.PR_CLOSED?.target; + const closedTargetLabel = closedTargetKey ? DEFAULT_WORKFLOW.states[closedTargetKey].label : undefined; + assert.ok(closedTargetLabel, "Expected workflow to define a PR_CLOSED target"); + assert.ok(issue.labels.includes(closedTargetLabel), `Labels: ${issue.labels}`); assert.ok(!issue.labels.includes("To Review"), "Should not have To Review"); assert.ok(!issue.labels.includes("To Test"), "Should NOT have To Test"); From a66cd2920e343717adee904fbd66c5bd8b9e4e05 Mon Sep 17 00:00:00 2001 From: fujiwaranosai850 Date: Sat, 6 Jun 2026 11:35:17 +0000 Subject: [PATCH 30/30] fix: tighten canonical review integrity guards (#244) --- lib/dispatch/pr-context.ts | 6 ++++++ lib/services/heartbeat/review.ts | 33 ++++++++++++++++---------------- 2 files changed, 23 insertions(+), 16 deletions(-) diff --git a/lib/dispatch/pr-context.ts b/lib/dispatch/pr-context.ts index 32a4b8a9..30b5d0d9 100644 --- a/lib/dispatch/pr-context.ts +++ b/lib/dispatch/pr-context.ts @@ -63,6 +63,9 @@ export async function fetchPrFeedback( if (canonicalUrl && !prStatus) { throw new Error(`Canonical PR routing integrity failure for issue #${issueId}: stored PR ${canonicalUrl} no longer resolves.`); } + if (canonicalUrl && !prStatus?.url) { + throw new Error(`Canonical PR routing integrity failure for issue #${issueId}: stored PR ${canonicalUrl} resolved without a canonical URL.`); + } if (!prStatus?.url || prStatus.state === PrState.MERGED || prStatus.state === PrState.CLOSED) { return undefined; } @@ -107,6 +110,9 @@ export async function fetchPrContext( if (canonicalUrl && !prStatus) { throw new Error(`Canonical PR routing integrity failure for issue #${issueId}: stored PR ${canonicalUrl} no longer resolves.`); } + if (canonicalUrl && !prStatus?.url) { + throw new Error(`Canonical PR routing integrity failure for issue #${issueId}: stored PR ${canonicalUrl} resolved without a canonical URL.`); + } if (!prStatus?.url) return undefined; const diff = canonicalRouting diff --git a/lib/services/heartbeat/review.ts b/lib/services/heartbeat/review.ts index 29dc7f64..7ed3d7b2 100644 --- a/lib/services/heartbeat/review.ts +++ b/lib/services/heartbeat/review.ts @@ -91,15 +91,16 @@ export async function reviewPass(opts: { canonical = await resolveCanonicalPrForIssue({ workspaceDir, projectSlug, issueId: issue.iid, provider }); } catch (err) { const refiningLabel = findRefiningLabel(workflow); + const summary = `Canonical PR routing integrity failed during review heartbeat: ${(err as Error).message ?? String(err)}`; + await provider.addComment(issue.iid, buildRefiningHoldComment({ + role: "reviewer", + result: "blocked", + from: state.label, + to: refiningLabel ?? state.label, + summary, + source: "system", + })); if (refiningLabel && refiningLabel !== state.label) { - await provider.addComment(issue.iid, buildRefiningHoldComment({ - role: "reviewer", - result: "blocked", - from: state.label, - to: refiningLabel, - summary: `Canonical PR routing integrity failed during review heartbeat: ${(err as Error).message ?? String(err)}`, - source: "system", - })); await provider.transitionLabel(issue.iid, state.label, refiningLabel); transitions++; } @@ -113,15 +114,15 @@ export async function reviewPass(opts: { if (!status) { const message = `Canonical PR routing integrity failure for issue #${issue.iid}: stored PR ${canonical.url} no longer resolves during review heartbeat.`; const refiningLabel = findRefiningLabel(workflow); + await provider.addComment(issue.iid, buildRefiningHoldComment({ + role: "reviewer", + result: "blocked", + from: state.label, + to: refiningLabel ?? state.label, + summary: message, + source: "system", + })); if (refiningLabel && refiningLabel !== state.label) { - await provider.addComment(issue.iid, buildRefiningHoldComment({ - role: "reviewer", - result: "blocked", - from: state.label, - to: refiningLabel, - summary: message, - source: "system", - })); await provider.transitionLabel(issue.iid, state.label, refiningLabel); transitions++; }