agents-relay 2.0.80 → 2.0.81

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -325,7 +325,10 @@ export class ReconcilerReviewBase extends ReconcilerRuntimeBase {
325
325
  return { applied: false, reconcile: false, reason: event.type + '.stale' };
326
326
  // Planner-created reviewer tasks predate the final-review Job gate. Bind
327
327
  // their first trustworthy review event to PENDING before dispatching it.
328
- const initialReviewOutcome = !job.review && task.planningDecisionId ? 'pending' : job.review?.outcome;
328
+ const freshAfterStale = job.review?.outcome === 'stale'
329
+ && job.review.headSha !== headSha && Boolean(task.planningDecisionId);
330
+ const initialReviewOutcome = (!job.review || freshAfterStale) && task.planningDecisionId
331
+ ? 'pending' : job.review?.outcome;
329
332
  const taskDecision = this.store.mutateJob
330
333
  ? dispatchTaskLifecycleSync(structuredClone(task), event.type)
331
334
  : tryTransitionTaskEvent(task, event.type);
@@ -395,7 +398,7 @@ export class ReconcilerReviewBase extends ReconcilerRuntimeBase {
395
398
  Object.assign(freshTask, candidate);
396
399
  // Bind the first Planner review only after the generation fence has
397
400
  // checked the unmodified outer snapshot and authoritative SQLite state.
398
- if (!current.review && freshTask.planningDecisionId) {
401
+ if ((!current.review || (current.review.outcome === 'stale' && current.review.headSha !== headSha)) && freshTask.planningDecisionId) {
399
402
  current.review = { outcome: 'pending', headSha, taskId: freshTask.id, evidence: null, updatedAt: reviewUpdatedAt };
400
403
  }
401
404
  freshTask.error = null;
@@ -1,3 +1,4 @@
1
+ import { normalizeReviewerTerminalEvent } from './review-terminal-normalization.js';
1
2
  import { applyJobEffectOutcomes, applyTaskEffectOutcomes } from './workflow-effect-failures.js';
2
3
  import { classifyWorkerRejection, reroutableWorkerRejections } from './worker-rejection.js';
3
4
  import { applyTaskRetryAction } from './task-actions.js';
@@ -89,6 +90,12 @@ export class ReconcilerRuntimeBase {
89
90
  return activeOwner.applyEvent(event);
90
91
  if (event.type === 'task.started')
91
92
  return this.markWorkerStarted(event);
93
+ if (event.type === 'task.completed') {
94
+ const task = (await this.store.load(event.job_id)).tasks.find(item => item.id === event.task_id);
95
+ const normalized = task?.agentName === 'reviewer' ? normalizeReviewerTerminalEvent(event) : null;
96
+ if (normalized)
97
+ return this.applyReviewEvent(normalized);
98
+ }
92
99
  if (['task.completed', 'task.failed', 'task.blocked', 'task.cancelled'].includes(event.type)) {
93
100
  const result = await this.applyTerminalEvent(event);
94
101
  if (event.type !== 'task.failed' || event.source?.watchdog !== true || event.data?.workflowEvent !== 'watchdog.failed' || result.reconcile)
@@ -5,6 +5,14 @@ export function reviewGenerationMatches(current, original, task, headSha) {
5
5
  || (current.headSha && current.headSha !== headSha)
6
6
  || fresh.executionId !== task.executionId)
7
7
  return false;
8
+ // A fresh Planner reviewer for a new head replaces the old STALE review
9
+ // generation. Reject competing reviewers of that same head deterministically.
10
+ if (current.review?.outcome === 'stale' && original.review?.outcome === 'stale'
11
+ && current.review.headSha !== headSha && task.planningDecisionId) {
12
+ const currentHeadReviewers = current.tasks.filter(item => item.agentName === 'reviewer'
13
+ && item.reviewHeadSha === headSha && item.state !== 'CANCELLED');
14
+ return currentHeadReviewers.at(-1)?.id === task.id;
15
+ }
8
16
  if (current.review)
9
17
  return current.review.taskId === task.id && current.review.headSha === headSha
10
18
  && current.review.outcome === (original.review?.outcome ?? 'pending');
@@ -0,0 +1,27 @@
1
+ function record(value) {
2
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
3
+ }
4
+ /** Translate an authenticated reviewer terminal verdict without losing canonical review evidence. */
5
+ export function normalizeReviewerTerminalEvent(event) {
6
+ if (event.type !== 'task.completed')
7
+ return null;
8
+ const data = record(event.data);
9
+ const raw = data.verdict;
10
+ const type = raw === 'approved' || raw === 'pass' ? 'review.approved'
11
+ : raw === 'changes-required' || raw === 'changes_required' ? 'review.changes_required'
12
+ : raw === 'blocked' ? 'review.blocked' : null;
13
+ if (!type)
14
+ return null;
15
+ const outcome = record(data.outcome);
16
+ const review = record(data.review);
17
+ // Match canonical review head resolution: outcome > review > top-level data.
18
+ const head = [outcome.head_sha, outcome.headSha, review.head_sha, review.headSha,
19
+ data.head_sha, data.headSha]
20
+ .find(value => typeof value === 'string' && value.length > 0);
21
+ const existingFindings = outcome.blocking_findings ?? outcome.blockingFindings;
22
+ const findings = existingFindings ?? (Array.isArray(data.findings) ? data.findings : undefined);
23
+ return { ...event, type, data: { ...data, outcome: {
24
+ ...outcome, ...(head ? { head_sha: head } : {}),
25
+ ...(findings !== undefined && existingFindings === undefined ? { blocking_findings: findings } : {}),
26
+ } } };
27
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-relay",
3
- "version": "2.0.80",
3
+ "version": "2.0.81",
4
4
  "description": "Durable async agent jobs coordinated through GitHub pull requests",
5
5
  "type": "module",
6
6
  "bin": {