From e9d256c06fabb2d3d78f1495590e5b8a87968e74 Mon Sep 17 00:00:00 2001 From: fujiwaranosai850 Date: Thu, 7 May 2026 00:16:17 +0000 Subject: [PATCH 1/5] 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 2/5] 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 3/5] 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 4/5] 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 dcd01aa241fe1e2f3df7520856b046ae30c63cf5 Mon Sep 17 00:00:00 2001 From: fujiwaranosai850 Date: Thu, 7 May 2026 09:08:07 +0000 Subject: [PATCH 5/5] fix: honor explicit human delivery acceptance --- lib/services/delivery-phases.test.ts | 12 +++--- lib/services/heartbeat/index.ts | 7 +++- lib/tools/admin/workflow-guide.ts | 2 + lib/workflow/candidate-provenance.ts | 58 ++++++++++++++++++++++++++++ 4 files changed, 70 insertions(+), 9 deletions(-) diff --git a/lib/services/delivery-phases.test.ts b/lib/services/delivery-phases.test.ts index a353d128..91988b2a 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, renderCandidateRecord } from "../workflow/index.js"; +import { DEFAULT_WORKFLOW, getCompletionRule, renderCandidateDecision, renderCandidateRecord } from "../workflow/index.js"; describe("delivery phase routing", () => { let h: TestHarness; @@ -110,7 +110,7 @@ describe("delivery phase routing", () => { }); }); - it("advances human-routed acceptance only after the candidate is explicitly accepted", async () => { + it("advances human-routed acceptance only after a human acceptance decision is recorded", async () => { h = await createTestHarness(); h.provider.seedIssue({ iid: 46, title: "Human accept", labels: ["To Accept", "acceptance:human"] }); await h.provider.addComment(46, renderCandidateRecord({ @@ -133,14 +133,12 @@ describe("delivery phase routing", () => { assert.strictEqual(before, 0); - await h.provider.addComment(46, renderCandidateRecord({ + await h.provider.addComment(46, renderCandidateDecision({ issueId: 46, candidateId: "cand-46", - commitSha: "def456", - targetHint: "candidate", status: "accepted", - promotedAt: new Date().toISOString(), - acceptedAt: new Date().toISOString(), + decidedAt: new Date().toISOString(), + reason: "Operator accepted promoted candidate", })); const after = await deliveryPass({ diff --git a/lib/services/heartbeat/index.ts b/lib/services/heartbeat/index.ts index 44d010c7..3bcf8d33 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.) @@ -165,6 +166,7 @@ async function processAllAgents( result.totalReviewTransitions += agentResult.totalReviewTransitions; result.totalReviewSkipTransitions += agentResult.totalReviewSkipTransitions; result.totalTestSkipTransitions += agentResult.totalTestSkipTransitions; + result.totalDeliveryTransitions += agentResult.totalDeliveryTransitions; } return result; @@ -182,10 +184,11 @@ function logTickResult( result.totalHealthFixes > 0 || result.totalReviewTransitions > 0 || result.totalReviewSkipTransitions > 0 || - result.totalTestSkipTransitions > 0 + result.totalTestSkipTransitions > 0 || + result.totalDeliveryTransitions > 0 ) { logger.info( - `work_heartbeat tick: ${result.totalPickups} pickups, ${result.totalHealthFixes} health fixes, ${result.totalReviewTransitions} review transitions, ${result.totalReviewSkipTransitions} review skips, ${result.totalTestSkipTransitions} test skips, ${result.totalSkipped} skipped`, + `work_heartbeat tick: ${result.totalPickups} pickups, ${result.totalHealthFixes} health fixes, ${result.totalReviewTransitions} review transitions, ${result.totalReviewSkipTransitions} review skips, ${result.totalTestSkipTransitions} test skips, ${result.totalDeliveryTransitions} delivery transitions, ${result.totalSkipped} skipped`, ); } } diff --git a/lib/tools/admin/workflow-guide.ts b/lib/tools/admin/workflow-guide.ts index b5ca1d91..ad7a3d5b 100644 --- a/lib/tools/admin/workflow-guide.ts +++ b/lib/tools/admin/workflow-guide.ts @@ -141,6 +141,8 @@ workflow: ## Routing labels - Promotion uses \`promotion:human\`, \`promotion:agent\`, \`promotion:skip\` - Acceptance uses \`acceptance:human\`, \`acceptance:agent\`, \`acceptance:skip\` +- Human-routed promotion waits for an explicit candidate record comment. +- Human-routed acceptance waits for an explicit candidate decision comment that marks the current candidate as \`accepted\`. ## 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.`; diff --git a/lib/workflow/candidate-provenance.ts b/lib/workflow/candidate-provenance.ts index 07b6eaa1..f3ce9044 100644 --- a/lib/workflow/candidate-provenance.ts +++ b/lib/workflow/candidate-provenance.ts @@ -2,6 +2,7 @@ import type { IssueProvider, IssueComment } from "../providers/provider.js"; import type { RunCommand } from "../context.js"; const MARKER = "devclaw:candidate-record"; +const DECISION_MARKER = "devclaw:candidate-decision"; export type CandidateStatus = "active" | "accepted" | "invalidated"; @@ -18,6 +19,14 @@ export type CandidateRecord = { reason?: string | null; }; +export type CandidateDecision = { + issueId: number; + status: Exclude; + candidateId?: string | null; + decidedAt: string; + reason?: string | null; +}; + export async function getCurrentCandidate(provider: IssueProvider, issueId: number): Promise { const comments = await provider.listComments(issueId); return findLatestCandidateRecord(comments); @@ -83,15 +92,54 @@ export function renderCandidateRecord(record: CandidateRecord): string { return lines.join("\n"); } +export function renderCandidateDecision(decision: CandidateDecision): string { + const payload = JSON.stringify(decision); + const lines = [ + ``, + "## DevClaw Candidate Decision", + "", + `- status: ${decision.status}`, + `- candidate: ${decision.candidateId ?? "current"}`, + ]; + if (decision.reason) lines.push(`- reason: ${decision.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 decision = parseCandidateDecision(comment?.body ?? ""); + if (decision) { + const base = findLatestCandidateBase(comments, i - 1, decision.candidateId ?? undefined); + if (!base) continue; + return applyDecision(base, decision); + } + const record = parseCandidateRecord(comment?.body ?? ""); if (record) return record; } return null; } +function findLatestCandidateBase(comments: IssueComment[], startIndex: number, candidateId?: string): CandidateRecord | null { + for (let i = startIndex; i >= 0; i--) { + const record = parseCandidateRecord(comments[i]?.body ?? ""); + if (!record) continue; + if (!candidateId || !record.candidateId || record.candidateId === candidateId) return record; + } + return null; +} + +function applyDecision(record: CandidateRecord, decision: CandidateDecision): CandidateRecord { + return { + ...record, + status: decision.status, + acceptedAt: decision.status === "accepted" ? decision.decidedAt : record.acceptedAt, + invalidatedAt: decision.status === "invalidated" ? decision.decidedAt : record.invalidatedAt, + reason: decision.reason ?? record.reason ?? null, + }; +} + function parseCandidateRecord(body: string): CandidateRecord | null { const match = body.match(new RegExp(``)); if (!match?.[1]) return null; @@ -102,6 +150,16 @@ function parseCandidateRecord(body: string): CandidateRecord | null { } } +function parseCandidateDecision(body: string): CandidateDecision | null { + const match = body.match(new RegExp(``)); + if (!match?.[1]) return null; + try { + return JSON.parse(match[1]) as CandidateDecision; + } 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 });