diff --git a/lib/services/delivery-phases.test.ts b/lib/services/delivery-phases.test.ts index a353d12..91988b2 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 44d010c..3bcf8d3 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 b5ca1d9..ad7a3d5 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 07b6eaa..f3ce904 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 });