Skip to content

Commit 5f081e0

Browse files
committed
feat: re-aligned revision cycles to clarify cycles
1 parent fddab41 commit 5f081e0

9 files changed

Lines changed: 105 additions & 45 deletions

File tree

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
-- Rename JobRecords.reviewCycle -> revisionCycle.
2+
-- The column models the revision round (round 1 at plan approval, +1 per human-triggered
3+
-- work episode), not a per-review-pass counter; the new name removes that ambiguity.
4+
ALTER TABLE "JobRecords" RENAME COLUMN "reviewCycle" TO "revisionCycle";

api/prisma/schema.prisma

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ model JobRecords {
5050
headSha String?
5151
confidence Int? // latest review confidence (0-100)
5252
verifyCommand String? // agent-discovered tests/build command; null/"" = none found yet (re-discovered until a real command appears)
53-
reviewCycle Int @default(0) // incremented each time handleImplement completes
53+
revisionCycle Int @default(0) // the revision round: starts at 1 on plan approval, +1 per human-triggered work episode (settled-PR changes). Verify attempts + review passes are budgeted per round.
5454
attempts Int @default(0)
5555
error String?
5656
createdAt DateTime @default(now())
@@ -198,7 +198,7 @@ model ReviewPass {
198198
id String @id @default(cuid())
199199
job JobRecords @relation(fields: [jobId], references: [id], onDelete: Cascade)
200200
jobId String
201-
cycle Int @default(1) // which implementation round (matches JobRecords.reviewCycle)
201+
cycle Int @default(1) // which revision round (matches JobRecords.revisionCycle)
202202
passNumber Int // pass within this cycle
203203
confidence Int // advisory only — the gate is the rubric + verify, not this number
204204
verdict String // ReviewVerdict union
@@ -218,7 +218,7 @@ model VerifyRun {
218218
id String @id @default(cuid())
219219
job JobRecords @relation(fields: [jobId], references: [id], onDelete: Cascade)
220220
jobId String
221-
cycle Int // implementation round (matches JobRecords.reviewCycle)
221+
cycle Int // revision round (matches JobRecords.revisionCycle)
222222
attempt Int // verify attempt within this cycle
223223
command String
224224
ok Boolean

api/src/job/job.model.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ export const ALLOWED_TRANSITIONS: Record<JobState, JobState[]> = {
3434
SELF_REVIEWING: ['REVISING', 'OPENING_PR'],
3535
REVISING: ['VERIFYING'],
3636
OPENING_PR: ['AWAITING_PR_APPROVAL', 'REVISING'],
37-
AWAITING_PR_APPROVAL: ['IMPLEMENTING', 'DONE'],
37+
AWAITING_PR_APPROVAL: ['REVISING', 'DONE'],
3838
DONE: [],
3939
FAILED: [],
4040
CANCELLED: [],
@@ -95,7 +95,7 @@ export interface JobSummaryDto {
9595
issueTitle: string;
9696
state: string;
9797
confidence: number | null;
98-
reviewCycle: number;
98+
revisionCycle: number;
9999
prNumber: number | null;
100100
prUrl: string | null;
101101
prIsDraft: boolean;

api/src/job/job.service.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,7 @@ export class JobService {
186186
issueTitle: job.issueTitle,
187187
state: job.state,
188188
confidence: job.confidence,
189-
reviewCycle: job.reviewCycle,
189+
revisionCycle: job.revisionCycle,
190190
prNumber: job.prNumber,
191191
prUrl: job.pr?.url ?? null,
192192
prIsDraft: job.pr?.isDraft ?? false,
@@ -273,7 +273,7 @@ export class JobService {
273273
issueBody: job.issueBody,
274274
state: job.state,
275275
confidence: job.confidence,
276-
reviewCycle: job.reviewCycle,
276+
revisionCycle: job.revisionCycle,
277277
prNumber: job.prNumber,
278278
prUrl: job.pr?.url ?? null,
279279
prIsDraft: job.pr?.isDraft ?? false,

api/src/job/job.utility.spec.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ describe('canTransition', () => {
1717
expect(canTransition('VERIFYING', 'REVISING')).toBe(true);
1818
expect(canTransition('SELF_REVIEWING', 'REVISING')).toBe(true);
1919
expect(canTransition('REVISING', 'VERIFYING')).toBe(true);
20-
expect(canTransition('AWAITING_PR_APPROVAL', 'IMPLEMENTING')).toBe(true);
20+
expect(canTransition('AWAITING_PR_APPROVAL', 'REVISING')).toBe(true);
21+
expect(canTransition('AWAITING_PR_APPROVAL', 'IMPLEMENTING')).toBe(false);
2122
});
2223

2324
it('forbids illegal jumps', () => {

api/src/orchestrator/orchestrator.service.spec.ts

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ function makeJob(overrides: Record<string, unknown> = {}) {
3030
return {
3131
id: 'job1',
3232
state: 'VERIFYING',
33-
reviewCycle: 1,
33+
revisionCycle: 1,
3434
issueNumber: 5,
3535
issueTitle: 'Title',
3636
issueBody: 'Body',
@@ -289,17 +289,35 @@ describe('OrchestratorService.handleVerify', () => {
289289
});
290290

291291
describe('OrchestratorService.handleImplement', () => {
292-
it('single pass → increments cycle, then verifies', async () => {
292+
it('single pass → verifies without bumping the revision round', async () => {
293293
const { service, queue, jobs, workspace } = setup({ job: { state: 'IMPLEMENTING' } });
294294

295295
await callPrivate(service, 'handleImplement', 'job1');
296296

297297
expect(workspace.commitAll).toHaveBeenCalled();
298-
expect(jobs.update).toHaveBeenCalledWith('job1', { reviewCycle: { increment: 1 } });
298+
// The round advances only at a human-episode transition (approval / settled-PR feedback),
299+
// never inside IMPLEMENT.
300+
expect(jobs.update).not.toHaveBeenCalledWith('job1', { revisionCycle: { increment: 1 } });
299301
expect(transitionedTo(jobs)).toContain('VERIFYING');
300302
expect(enqueuedKinds(queue)).toEqual(['VERIFY']);
301303
});
302304

305+
it('only folds in PR feedback newer than the last implement run', async () => {
306+
const { service, prisma } = setup({ job: { state: 'IMPLEMENTING', prNumber: 7 } });
307+
const anchor = new Date(1000);
308+
prisma.agentRun.findFirst.mockResolvedValue({ createdAt: anchor });
309+
310+
await callPrivate(service, 'handleImplement', 'job1');
311+
312+
// Older, already-addressed feedback rounds must be excluded — the query is time-scoped to
313+
// the last implement pass, not the whole job's feedback history.
314+
expect(prisma.pullRequestFeedback.findMany).toHaveBeenCalledWith(
315+
expect.objectContaining({
316+
where: expect.objectContaining({ createdAt: { gt: anchor } }),
317+
}),
318+
);
319+
});
320+
303321
it('marks the run FAILED and throws when nothing was committed', async () => {
304322
const { service, agent, workspace, queue } = setup({ job: { state: 'IMPLEMENTING' } });
305323
workspace.commitAll.mockResolvedValue(null);
@@ -461,6 +479,8 @@ describe('OrchestratorService.approvePlan', () => {
461479
where: { jobId: 'job1', status: 'PROPOSED' },
462480
data: { status: 'APPROVED' },
463481
});
482+
// Approval opens revision round 1.
483+
expect(jobs.update).toHaveBeenCalledWith('job1', { revisionCycle: { increment: 1 } });
464484
expect(transitionedTo(jobs)).toContain('IMPLEMENTING');
465485
expect(enqueuedKinds(queue)).toEqual(['IMPLEMENT']);
466486
});
@@ -529,15 +549,17 @@ describe('OrchestratorService.onPullRequestReview (changes requested)', () => {
529549
isBot: false,
530550
};
531551

532-
it('records feedback and restarts the cycle when the PR is awaiting approval', async () => {
552+
it('opens a new revision round (REVISE) when the PR is awaiting approval', async () => {
533553
const { service, prisma, queue, jobs } = setup({ job: { state: 'AWAITING_PR_APPROVAL' } });
534554
prisma.jobRecords.findFirst.mockResolvedValue(makeJob({ state: 'AWAITING_PR_APPROVAL' }));
535555

536556
await service.onPullRequestReview(prEvent as never);
537557

538558
expect(prisma.pullRequestFeedback.create).toHaveBeenCalled();
539-
expect(transitionedTo(jobs)).toContain('IMPLEMENTING');
540-
expect(enqueuedKinds(queue)).toEqual(['IMPLEMENT']);
559+
// A settled PR re-enters via a scoped REVISE in a fresh round, not a full IMPLEMENT.
560+
expect(jobs.update).toHaveBeenCalledWith('job1', { revisionCycle: { increment: 1 } });
561+
expect(transitionedTo(jobs)).toContain('REVISING');
562+
expect(enqueuedKinds(queue)).toEqual(['REVISE']);
541563
});
542564

543565
it('records feedback but does NOT restart when the job is already working mid-cycle', async () => {
@@ -649,15 +671,16 @@ describe('OrchestratorService dashboard actions', () => {
649671
expect(jobs.transition).not.toHaveBeenCalled();
650672
});
651673

652-
it('requestChanges records feedback and restarts IMPLEMENT', async () => {
674+
it('requestChanges records feedback and opens a new REVISE round', async () => {
653675
const { service, prisma, jobs, queue } = setup({ job: { state: 'AWAITING_PR_APPROVAL' } });
654676

655677
const res = await service.requestChanges('job1', 'dashboard', 'fix Y');
656678

657679
expect(res.ok).toBe(true);
658680
expect(prisma.pullRequestFeedback.create).toHaveBeenCalled();
659-
expect(transitionedTo(jobs)).toContain('IMPLEMENTING');
660-
expect(enqueuedKinds(queue)).toEqual(['IMPLEMENT']);
681+
expect(jobs.update).toHaveBeenCalledWith('job1', { revisionCycle: { increment: 1 } });
682+
expect(transitionedTo(jobs)).toContain('REVISING');
683+
expect(enqueuedKinds(queue)).toEqual(['REVISE']);
661684
});
662685

663686
it('setRepo updates the repo and discards the workspace while editable', async () => {

api/src/orchestrator/orchestrator.service.ts

Lines changed: 57 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,7 @@ export class OrchestratorService {
185185
orderBy: { createdAt: 'desc' },
186186
select: { phase: true, createdAt: true },
187187
}),
188-
this.prisma.reviewPass.count({ where: { jobId: job.id, cycle: job.reviewCycle } }),
188+
this.prisma.reviewPass.count({ where: { jobId: job.id, cycle: job.revisionCycle } }),
189189
this.prisma.queueTask.findFirst({
190190
where: { jobId: job.id, status: { in: ['PENDING', 'RUNNING'] } },
191191
orderBy: { createdAt: 'desc' },
@@ -196,7 +196,7 @@ export class OrchestratorService {
196196
select: { prNumber: true },
197197
}),
198198
this.prisma.reviewPass.findFirst({
199-
where: { jobId: job.id, cycle: job.reviewCycle },
199+
where: { jobId: job.id, cycle: job.revisionCycle },
200200
orderBy: { passNumber: 'desc' },
201201
select: { issues: true, verifyOk: true, dimensions: true },
202202
}),
@@ -274,9 +274,9 @@ export class OrchestratorService {
274274
return;
275275
}
276276

277-
// /hermes revise on a PR thread: store feedback and enqueue IMPLEMENT only when
278-
// the job is parked (AWAITING_PR_APPROVAL). If an agent is already running the
279-
// feedback is persisted and will be picked up on the next handleImplement call.
277+
// /hermes revise on a PR thread: store feedback and open a new revision round (REVISE)
278+
// only when the job is parked (AWAITING_PR_APPROVAL). If an agent is already running the
279+
// feedback is persisted and folds into the in-flight round on the next revise call.
280280
if (command.kind === 'revise' && job.prNumber) {
281281
const prefix = this.config.get('COMMAND_PREFIX');
282282

@@ -297,12 +297,16 @@ export class OrchestratorService {
297297
await this.safeCommentReaction(ref, evt.commentId, 'eyes');
298298

299299
if (job.state === 'AWAITING_PR_APPROVAL') {
300-
await this.jobs.transition(job.id, 'IMPLEMENTING', {
300+
// A settled PR + new feedback = a fresh revision round: bump the round (fresh verify/
301+
// review budget) and run REVISE, scoped to this feedback — not a full re-implement.
302+
await this.jobs.update(job.id, { revisionCycle: { increment: 1 } });
303+
304+
await this.jobs.transition(job.id, 'REVISING', {
301305
reason: `revision requested by @${evt.author}`,
302306
actor: 'HUMAN',
303307
});
304308

305-
await this.queue.enqueue({ jobId: job.id, kind: 'IMPLEMENT' });
309+
await this.queue.enqueue({ jobId: job.id, kind: 'REVISE' });
306310

307311
await this.safeComment(
308312
ref,
@@ -410,13 +414,16 @@ export class OrchestratorService {
410414
});
411415

412416
if (job.state === 'AWAITING_PR_APPROVAL') {
413-
// The PR was settled — start a fresh revision cycle to address the feedback.
414-
await this.jobs.transition(job.id, 'IMPLEMENTING', {
417+
// The PR was settled — open a fresh revision round (fresh verify/review budget) and
418+
// run REVISE, scoped to this feedback, rather than re-running the full IMPLEMENT.
419+
await this.jobs.update(job.id, { revisionCycle: { increment: 1 } });
420+
421+
await this.jobs.transition(job.id, 'REVISING', {
415422
reason: `changes requested by @${evt.author}`,
416423
actor: 'HUMAN',
417424
});
418425

419-
await this.queue.enqueue({ jobId: job.id, kind: 'IMPLEMENT' });
426+
await this.queue.enqueue({ jobId: job.id, kind: 'REVISE' });
420427

421428
await this.safeComment(
422429
ref,
@@ -581,6 +588,9 @@ export class OrchestratorService {
581588
data: { status: 'APPROVED' },
582589
});
583590

591+
// Plan approval opens revision round 1 — the first (and only) IMPLEMENT pass.
592+
await this.jobs.update(jobId, { revisionCycle: { increment: 1 } });
593+
584594
await this.jobs.transition(jobId, 'IMPLEMENTING', {
585595
reason: `plan approved by ${by}`,
586596
actor: 'HUMAN',
@@ -700,12 +710,16 @@ export class OrchestratorService {
700710

701711
await this.prisma.pullRequestFeedback.create({ data: { jobId, author: by, body } });
702712

703-
await this.jobs.transition(jobId, 'IMPLEMENTING', {
713+
// Settled result + new feedback = a fresh revision round (fresh verify/review budget),
714+
// addressed via a scoped REVISE rather than a full re-implement.
715+
await this.jobs.update(jobId, { revisionCycle: { increment: 1 } });
716+
717+
await this.jobs.transition(jobId, 'REVISING', {
704718
reason: `changes requested by ${by}`,
705719
actor: 'HUMAN',
706720
});
707721

708-
await this.queue.enqueue({ jobId, kind: 'IMPLEMENT' });
722+
await this.queue.enqueue({ jobId, kind: 'REVISE' });
709723

710724
return { ok: true };
711725
}
@@ -955,10 +969,22 @@ export class OrchestratorService {
955969

956970
// PR/result feedback applies once a PR exists (GitHub) or for any dashboard job, where
957971
// "request changes" stores feedback without a PR number. Older GitHub jobs with no PR
958-
// yet have nothing to fold in.
972+
// yet have nothing to fold in. Only feedback submitted *after* the last implement run is
973+
// relevant — anything older was already incorporated by that pass; re-injecting it makes a
974+
// fresh cycle re-attack already-resolved rounds (and mislabel them as current corrections).
975+
// Mirrors the same guard in handleRevise.
959976
if (job.prNumber || job.origin === 'DASHBOARD') {
977+
const lastImplementRun = await this.prisma.agentRun.findFirst({
978+
where: { jobId, phase: 'IMPLEMENT' },
979+
orderBy: { createdAt: 'desc' },
980+
select: { createdAt: true },
981+
});
982+
960983
const prRevisions = await this.prisma.pullRequestFeedback.findMany({
961-
where: { jobId },
984+
where: {
985+
jobId,
986+
...(lastImplementRun ? { createdAt: { gt: lastImplementRun.createdAt } } : {}),
987+
},
962988
orderBy: { createdAt: 'asc' },
963989
});
964990

@@ -1015,7 +1041,10 @@ export class OrchestratorService {
10151041
});
10161042

10171043
await this.jobs.incrementAttempts(jobId);
1018-
await this.jobs.update(jobId, { reviewCycle: { increment: 1 } });
1044+
// NB: revisionCycle is NOT incremented here. The round advances only on a human-triggered
1045+
// work episode (plan approval → round 1; each settled-PR changes request → +1), set at that
1046+
// transition. Incrementing per IMPLEMENT pass is the old coupling that forced PR feedback to
1047+
// route through IMPLEMENT just to get a fresh verify/review budget.
10191048

10201049
await this.jobs.transition(jobId, 'VERIFYING', {
10211050
reason: 'implementation complete',
@@ -1181,7 +1210,7 @@ export class OrchestratorService {
11811210
branchName: this.branchFor(job),
11821211
});
11831212

1184-
const cycle = job.reviewCycle;
1213+
const cycle = job.revisionCycle;
11851214
const verifyCommand = await this.verifyCommandFor(job, ws.dir);
11861215

11871216
// No automated checks in the repo (yet) — nothing to run, proceed to self-review.
@@ -1283,25 +1312,28 @@ export class OrchestratorService {
12831312

12841313
const plan = await this.approvedPlan(jobId);
12851314

1286-
// PR feedback is only relevant if it was submitted after the last IMPLEMENT run;
1287-
// anything older was already incorporated into the code by that IMPLEMENT pass.
1288-
const lastImplementRun = await this.prisma.agentRun.findFirst({
1289-
where: { jobId, phase: 'IMPLEMENT' },
1315+
// PR feedback is only relevant if it was submitted after the last WORK run (IMPLEMENT or
1316+
// REVISE); anything older was already incorporated by that pass. Anchoring on either phase
1317+
// (not just IMPLEMENT) keeps each round isolated now that feedback drives REVISE, not
1318+
// IMPLEMENT — otherwise the anchor would freeze at the round-1 build and re-inject every
1319+
// prior round's feedback.
1320+
const lastWorkRun = await this.prisma.agentRun.findFirst({
1321+
where: { jobId, phase: { in: ['IMPLEMENT', 'REVISE'] } },
12901322
orderBy: { createdAt: 'desc' },
12911323
select: { createdAt: true },
12921324
});
12931325

12941326
const [reviewPasses, prFeedback] = await Promise.all([
12951327
this.prisma.reviewPass.findMany({
1296-
where: { jobId, cycle: job.reviewCycle },
1328+
where: { jobId, cycle: job.revisionCycle },
12971329
orderBy: { passNumber: 'desc' },
12981330
take: 2,
12991331
select: { issues: true },
13001332
}),
13011333
this.prisma.pullRequestFeedback.findMany({
13021334
where: {
13031335
jobId,
1304-
...(lastImplementRun ? { createdAt: { gt: lastImplementRun.createdAt } } : {}),
1336+
...(lastWorkRun ? { createdAt: { gt: lastWorkRun.createdAt } } : {}),
13051337
},
13061338
orderBy: { createdAt: 'asc' },
13071339
}),
@@ -1329,7 +1361,7 @@ export class OrchestratorService {
13291361
// If the most recent verify in this cycle failed, the build/tests are the priority
13301362
// fix — surface their output to the revise agent.
13311363
const lastVerify = await this.prisma.verifyRun.findFirst({
1332-
where: { jobId, cycle: job.reviewCycle },
1364+
where: { jobId, cycle: job.revisionCycle },
13331365
orderBy: { createdAt: 'desc' },
13341366
select: { ok: true, command: true, output: true },
13351367
});
@@ -1410,7 +1442,7 @@ export class OrchestratorService {
14101442
const base = ws.baseBranch;
14111443
const plan = await this.approvedPlan(jobId);
14121444
const maxPasses = this.review.maxPasses;
1413-
const cycle = job.reviewCycle;
1445+
const cycle = job.revisionCycle;
14141446

14151447
// One ordered read of this cycle's passes backs three things: the pass number, the
14161448
// immediately-preceding pass's issues (so the reviewer can verify each was resolved), and the
@@ -1673,7 +1705,7 @@ export class OrchestratorService {
16731705
await this.jobs.update(jobId, { headSha });
16741706

16751707
const lastReview = await this.prisma.reviewPass.findFirst({
1676-
where: { jobId, cycle: job.reviewCycle },
1708+
where: { jobId, cycle: job.revisionCycle },
16771709
orderBy: { passNumber: 'desc' },
16781710
});
16791711

app/src/components/JobCard.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,8 +88,8 @@ export default function JobCard({ job }: Props) {
8888
)}
8989
</td>
9090
<td class="py-3 px-4 whitespace-nowrap">
91-
{job.reviewCycle > 0 ? (
92-
<span class="text-xs text-zinc-400">cycle {job.reviewCycle}</span>
91+
{job.revisionCycle > 0 ? (
92+
<span class="text-xs text-zinc-400">revision {job.revisionCycle}</span>
9393
) : (
9494
<span class="text-zinc-700"></span>
9595
)}

0 commit comments

Comments
 (0)