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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 5 additions & 7 deletions lib/services/delivery-phases.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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({
Expand All @@ -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({
Expand Down
7 changes: 5 additions & 2 deletions lib/services/heartbeat/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.)
Expand Down Expand Up @@ -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;
Expand All @@ -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`,
);
}
}
Expand Down
2 changes: 2 additions & 0 deletions lib/tools/admin/workflow-guide.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.`;
Expand Down
58 changes: 58 additions & 0 deletions lib/workflow/candidate-provenance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -18,6 +19,14 @@ export type CandidateRecord = {
reason?: string | null;
};

export type CandidateDecision = {
issueId: number;
status: Exclude<CandidateStatus, "active">;
candidateId?: string | null;
decidedAt: string;
reason?: string | null;
};

export async function getCurrentCandidate(provider: IssueProvider, issueId: number): Promise<CandidateRecord | null> {
const comments = await provider.listComments(issueId);
return findLatestCandidateRecord(comments);
Expand Down Expand Up @@ -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 = [
`<!-- ${DECISION_MARKER} ${payload} -->`,
"## 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(`<!--\\s*${MARKER}\\s+(.+?)\\s*-->`));
if (!match?.[1]) return null;
Expand All @@ -102,6 +150,16 @@ function parseCandidateRecord(body: string): CandidateRecord | null {
}
}

function parseCandidateDecision(body: string): CandidateDecision | null {
const match = body.match(new RegExp(`<!--\\s*${DECISION_MARKER}\\s+(.+?)\\s*-->`));
if (!match?.[1]) return null;
try {
return JSON.parse(match[1]) as CandidateDecision;
} catch {
return null;
}
}

async function getHeadSha(repoPath: string, runCommand: RunCommand): Promise<string | null> {
try {
const result = await runCommand(["git", "rev-parse", "HEAD"], { cwd: repoPath, timeoutMs: 10_000 });
Expand Down