agents-relay 2.0.80 → 2.0.82

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.
Files changed (41) hide show
  1. package/README.md +3 -3
  2. package/dist/cli-help.js +5 -8
  3. package/dist/cli-review.js +1 -1
  4. package/dist/cli-support.js +2 -22
  5. package/dist/cli-worker-task.js +7 -6
  6. package/dist/cli.js +22 -59
  7. package/dist/dashboard-actions.js +2 -1
  8. package/dist/dashboard-html.js +5 -5
  9. package/dist/dashboard.js +0 -4
  10. package/dist/github-binding-order.js +1 -1
  11. package/dist/job-pr-number-migration.js +24 -0
  12. package/dist/job-task-id-sequence.js +16 -9
  13. package/dist/legacy-orchestrator-repair.js +51 -0
  14. package/dist/local-job-registry.js +2 -3
  15. package/dist/local-merge-delivery.js +1 -1
  16. package/dist/managed-worker-input.js +3 -4
  17. package/dist/planner.js +14 -8
  18. package/dist/pool.js +3 -1
  19. package/dist/reconciler-review.js +7 -4
  20. package/dist/reconciler-runtime.js +7 -0
  21. package/dist/reconciler.js +2 -8
  22. package/dist/relay-store.js +6 -4
  23. package/dist/relayd-control.js +10 -0
  24. package/dist/relayd-dashboard-actions.js +3 -33
  25. package/dist/relayd.js +17 -20
  26. package/dist/review-generation-fence.js +8 -0
  27. package/dist/review-terminal-normalization.js +27 -0
  28. package/dist/reviewer-thread.js +1 -1
  29. package/dist/runtime-status.js +2 -1
  30. package/dist/task-actions.js +47 -42
  31. package/dist/task-title.js +5 -0
  32. package/dist/troubleshooter.js +2 -2
  33. package/dist/worker-routing.js +1 -1
  34. package/dist/workflow/entity-creation.js +11 -1
  35. package/dist/workflow/orchestrator-rescue.js +62 -18
  36. package/dist/workflow/troubleshooter-v2.js +1 -1
  37. package/dist/workspace.js +5 -4
  38. package/package.json +1 -1
  39. package/skills/agents-relay/contracts/orchestrator-workflows.md +13 -12
  40. package/workflow/orchestrator-rescue.mmd +16 -1
  41. package/workflow/troubleshooter.mmd +4 -4
@@ -4,9 +4,8 @@ import { createReviewChairExecutionPlan } from './review-chair.js';
4
4
  import { taskTagWorkerPrompt } from './task-tag.js';
5
5
  import { executionEventSource } from './reconciler-runtime.js';
6
6
  export async function managedWorkerInput(job, task, context) {
7
- const isLocalJob = job.prNumber === 0;
8
- const externalPrNumber = job.externalPrNumber && job.externalPrNumber > 0 ? job.externalPrNumber : (job.prNumber > 0 ? job.prNumber : 0);
9
- const projectedReview = isLocalJob && task.agentName === 'reviewer' && externalPrNumber > 0;
7
+ const isLocalJob = Boolean(job.localMerge?.sourceRef);
8
+ const projectedReview = isLocalJob && task.agentName === 'reviewer' && job.prNumber > 0;
10
9
  if (!['codex', 'chatgpt'].includes(task.adapter) || !job.repository)
11
10
  return task.input;
12
11
  const selectedAgent = task.agentName && context ? await agentContractPrompt(context, task.agentName) : '';
@@ -19,7 +18,7 @@ export async function managedWorkerInput(job, task, context) {
19
18
  if (!task.executionId)
20
19
  throw new Error(`Task ${task.id} requires an executionId before worker input is built`);
21
20
  const sourceId = executionEventSource(job.id, task.id, task.executionId);
22
- const prUrl = externalPrNumber > 0 ? `https://github.com/${job.repository}/pull/${externalPrNumber}` : null;
21
+ const prUrl = job.prNumber > 0 ? `https://github.com/${job.repository}/pull/${job.prNumber}` : null;
23
22
  const taskUrl = task.taskUrl ?? (isLocalJob ? `local:${job.id}/task/${task.id}` : `${prUrl}#issuecomment-${task.id}`);
24
23
  const output = task.output.kind === 'file'
25
24
  ? `file\nPath: ${task.output.path}`
package/dist/planner.js CHANGED
@@ -16,7 +16,7 @@ export function semanticTaskTree(tasks) {
16
16
  const ids = new Set(work.map(task => task.id));
17
17
  return {
18
18
  roots: work.filter(task => !task.parentTaskId || !ids.has(task.parentTaskId)).map(task => task.id),
19
- tasks: work.map(task => ({ id: task.id, parent_task_id: task.parentTaskId, state: task.state, dependencies: [...task.dependencies], planning_decision_id: task.planningDecisionId, planning_round: task.planningRound, review: task.review })),
19
+ tasks: work.map(task => ({ id: task.id, title: task.title, parent_task_id: task.parentTaskId, state: task.state, dependencies: [...task.dependencies], planning_decision_id: task.planningDecisionId, planning_round: task.planningRound, review: task.review })),
20
20
  };
21
21
  }
22
22
  function plannerTrigger(job, manual = false) {
@@ -113,14 +113,14 @@ export function materializePlannerChild(job, planner, spec, executionContext) {
113
113
  validateResolutionProposal(job, target, resolution);
114
114
  }
115
115
  const reviewerThread = spec.agentName === 'reviewer' ? prepareReviewerThreadDelivery(job, { id: spec.id, agentName: spec.agentName, reviewHeadSha }) : undefined;
116
- return createTaskEntity(job, { jobId: job.id, id: spec.id, kind: 'work', priority: spec.priority ?? job.priority, storySize: spec.storySize ?? 'M', projectName: spec.projectName, agentName: spec.agentName, agentGraph: spec.agentGraph, review: spec.review, parentTaskId: spec.parentTaskId ?? null, dependencies: spec.dependencies ?? [], capabilities: spec.capabilities ?? [], workerRequirements: reviewerThread ? chatgptThreadRequirements(spec.workerRequirements) : spec.workerRequirements, testInjection: spec.testInjection, adapter: reviewerThread ? 'chatgpt' : 'codex', chatgptThreadDelivery: reviewerThread, input: spec.input, output: spec.output, continuation: spec.continuation ?? null, continuationDeliveredAt: null, attempt: 0, maxAttempts: spec.maxAttempts ?? 3, leaseOwner: null, leaseExpiresAt: null, executionId: null, threadId: null, result: null, plannerResult: null, error: null, timeoutMs: spec.timeoutMs ?? taskOverallTimeoutMs(spec.storySize ?? 'M'), createdAt: now, updatedAt: now, planningDecisionId: decisionId, planningSource: source, planningRound: round, reviewHeadSha, resolution: resolution ? { ...resolution, state: 'proposed', owner: 'planner', updatedAt: now } : undefined });
116
+ return createTaskEntity(job, { jobId: job.id, id: spec.id, title: spec.title, kind: 'work', priority: spec.priority ?? job.priority, storySize: spec.storySize ?? 'M', projectName: spec.projectName, agentName: spec.agentName, agentGraph: spec.agentGraph, review: spec.review, parentTaskId: spec.parentTaskId ?? null, dependencies: spec.dependencies ?? [], capabilities: spec.capabilities ?? [], workerRequirements: reviewerThread ? chatgptThreadRequirements(spec.workerRequirements) : spec.workerRequirements, testInjection: spec.testInjection, adapter: reviewerThread ? 'chatgpt' : 'codex', chatgptThreadDelivery: reviewerThread, input: spec.input, output: spec.output, continuation: spec.continuation ?? null, continuationDeliveredAt: null, attempt: 0, maxAttempts: spec.maxAttempts ?? 3, leaseOwner: null, leaseExpiresAt: null, executionId: null, threadId: null, result: null, plannerResult: null, error: null, timeoutMs: spec.timeoutMs ?? taskOverallTimeoutMs(spec.storySize ?? 'M'), createdAt: now, updatedAt: now, planningDecisionId: decisionId, planningSource: source, planningRound: round, reviewHeadSha, resolution: resolution ? { ...resolution, state: 'proposed', owner: 'planner', updatedAt: now } : undefined });
117
117
  }
118
118
  export function planningSpec(task) {
119
- if (!task.output && task.adapter !== 'orchestrator')
119
+ if (!task.output)
120
120
  throw new Error(`Initial task ${task.id} requires an output contract`);
121
- if (!['codex', 'chatgpt', 'orchestrator'].includes(task.adapter))
122
- throw new Error(`Initial task ${task.id} requires a supported adapter`);
123
- return { id: task.id, parentTaskId: task.parentTaskId, input: task.input, priority: task.priority, storySize: task.storySize, projectName: task.projectName, agentName: task.agentName, agentGraph: task.agentGraph, review: task.review, dependencies: task.dependencies, capabilities: task.capabilities, workerRequirements: task.workerRequirements, testInjection: task.testInjection, adapter: task.adapter, output: task.output ?? { kind: 'task_pr' }, routing: task.routing, continuation: task.continuation, maxAttempts: task.maxAttempts, timeoutMs: task.timeoutMs, resolution: task.resolution ? { ...task.resolution, state: undefined, owner: undefined, updatedAt: undefined } : undefined };
121
+ if (!['codex', 'chatgpt'].includes(task.adapter))
122
+ throw new Error(`Initial task ${task.id} requires a supported worker adapter`);
123
+ return { id: task.id, title: task.title, parentTaskId: task.parentTaskId, input: task.input, priority: task.priority, storySize: task.storySize, projectName: task.projectName, agentName: task.agentName, agentGraph: task.agentGraph, review: task.review, dependencies: task.dependencies, capabilities: task.capabilities, workerRequirements: task.workerRequirements, testInjection: task.testInjection, adapter: task.adapter, output: task.output ?? { kind: 'task_pr' }, routing: task.routing, continuation: task.continuation, maxAttempts: task.maxAttempts, timeoutMs: task.timeoutMs, resolution: task.resolution ? { ...task.resolution, state: undefined, owner: undefined, updatedAt: undefined } : undefined };
124
124
  }
125
125
  function record(value, field = 'result') {
126
126
  if (!value || typeof value !== 'object' || Array.isArray(value))
@@ -309,7 +309,7 @@ export function parsePlannerResult(value) {
309
309
  if (typeof timeoutMs !== 'number' || !Number.isFinite(timeoutMs) || timeoutMs < 0)
310
310
  throw new Error(`Planner next_tasks[${index}].timeoutMs is invalid`);
311
311
  return {
312
- id, parentTaskId, input: task.input, priority, storySize,
312
+ id, title: optionalString(task.title, `next_tasks[${index}].title`), parentTaskId, input: task.input, priority, storySize,
313
313
  projectName: optionalString(task.projectName, 'projectName'), agentName: optionalString(task.agentName, 'agentName'), agentGraph, review,
314
314
  dependencies: stringArray(task.dependencies, 'dependencies'), capabilities: stringArray(task.capabilities, 'capabilities'), workerRequirements: requirements,
315
315
  testInjection: parseTestInjection(task.testInjection ?? task['--test-injection']) ?? optionalString(task.testInjection ?? task['--test-injection'], `next_tasks[${index}].--test-injection`),
@@ -331,7 +331,7 @@ export function validatePlannerProgress(job, result) {
331
331
  throw new Error('Planner returned in_progress with no executable tasks or unblocks while no live work remains; return satisfied when only Relay-owned review/merge/completion effects remain');
332
332
  }
333
333
  export function validatePlannerLifecycleOwnership(job, specs) {
334
- if (job.prNumber !== 0 || !job.localMerge)
334
+ if (!job.localMerge?.sourceRef)
335
335
  return;
336
336
  for (const spec of specs) {
337
337
  // Restrict ownership detection to an individual imperative clause. A long
@@ -347,6 +347,11 @@ export function validatePlannerLifecycleOwnership(job, specs) {
347
347
  if (ownsLifecycle && [localMerge, jobCompletion, externalMerge].filter(Boolean).length >= 2) {
348
348
  throw new Error(`Planner next task ${spec.id} attempts to own Graph lifecycle delivery; local merge, Job COMPLETED, and projected PR merge are Relay-owned effects and must not be worker Tasks`);
349
349
  }
350
+ const releaseEffect = /\b(?:publish|release|deploy)\b.{0,100}\b(?:package|npm|service|runtime|version)\b|\b(?:package|npm|service|runtime)\b.{0,100}\b(?:publish|release|deploy)\b/.test(clause);
351
+ const releaseImperative = /^\s*(?:publish|release|deploy)\b|\b(?:then|and)\s+(?:publish|release|deploy)\b/.test(clause);
352
+ if (releaseEffect && releaseImperative) {
353
+ throw new Error(`Planner next task ${spec.id} attempts to own Relay release delivery; package publication and service/runtime deployment are Relay-owned effects and must not be worker Tasks`);
354
+ }
350
355
  }
351
356
  }
352
357
  }
@@ -431,6 +436,7 @@ export function plannerInput(context) {
431
436
  visible_agents: visibleAgents ?? [],
432
437
  tasks: context.tasks.map(task => ({
433
438
  id: task.id,
439
+ title: task.title,
434
440
  kind: task.kind ?? 'work',
435
441
  parent_task_id: task.parentTaskId,
436
442
  state: task.state,
package/dist/pool.js CHANGED
@@ -12,7 +12,9 @@ export function needsLifecycleReconciliation(job, now = Date.now()) {
12
12
  // DRAFT create-only Jobs stay dormant until a caller explicitly creates work.
13
13
  if (job.tasks.length === 0)
14
14
  return false;
15
- if (job.tasks.some(task => (task.state === 'QUEUED' && task.executionId === null) || (task.state === 'DRAFT' && task.dependencies.length > 0)))
15
+ if (job.tasks.some(task => (task.state === 'QUEUED' && task.executionId === null)
16
+ || (task.state === 'RUNNING' && task.adapter === 'orchestrator' && task.executionId === null)
17
+ || (task.state === 'DRAFT' && task.dependencies.length > 0)))
16
18
  return true;
17
19
  return job.tasks.some(task => ['RUNNING', 'QUEUED'].includes(task.state) && task.executionId !== null && task.leaseExpiresAt !== null && new Date(task.leaseExpiresAt).getTime() <= now);
18
20
  }
@@ -122,7 +122,7 @@ export class ReconcilerReviewBase extends ReconcilerRuntimeBase {
122
122
  throw new Error('Too many final review rounds for ' + headSha);
123
123
  }
124
124
  externalReviewPrNumber(job) {
125
- return job.externalPrNumber && job.externalPrNumber > 0 ? job.externalPrNumber : job.prNumber;
125
+ return job.prNumber;
126
126
  }
127
127
  async createFinalReviewTask(job, headSha) {
128
128
  const id = this.nextFinalReviewTaskId(job, headSha);
@@ -163,7 +163,7 @@ export class ReconcilerReviewBase extends ReconcilerRuntimeBase {
163
163
  // Local-first Jobs may expose a review-only GitHub PR. Once such a PR is
164
164
  // bound, its projected head must match the authoritative local head before
165
165
  // a reviewer is asked to review that immutable PR identity.
166
- if (!job.externalPrNumber || job.externalPrNumber <= 0 || !this.store.pullRequestHead)
166
+ if (!job.localMerge?.sourceRef || job.prNumber <= 0 || !this.store.pullRequestHead)
167
167
  return true;
168
168
  try {
169
169
  const projected = await this.store.pullRequestHead();
@@ -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)
@@ -28,7 +28,7 @@ export { managedWorkerInput } from './managed-worker-input.js';
28
28
  import { recoverExpiredExecutions, recoverStartupOrphans as recoverStartupExecutionOrphans } from './execution-recovery.js';
29
29
  import { dispatchJobReconcile } from './workflow/job-reconcile-v2.js';
30
30
  import { reconcileLocalGitCompletion } from './local-merge-delivery.js';
31
- import { applyOrchestratorRescue } from './workflow/orchestrator-rescue.js';
31
+ import { repairLegacyOrchestrators } from './legacy-orchestrator-repair.js';
32
32
  import { launchPlanExecution } from './planner-runtime.js';
33
33
  import { classifyFailure, errorMessage, observability, withObservabilityCorrelation } from './observability.js';
34
34
  const PRIORITY_RANK = { P0: 0, P1: 1, P2: 2, P3: 3 };
@@ -93,13 +93,7 @@ export class Reconciler extends ReconcilerReviewBase {
93
93
  if (job.state !== 'ACTIVE')
94
94
  return job;
95
95
  await recoverExpiredExecutions(job, now, this.executionRecoveryPorts(job));
96
- for (const task of (await this.store.load(jobId)).tasks) {
97
- const rescue = applyOrchestratorRescue(task, now);
98
- if (!rescue.rescued)
99
- continue;
100
- await this.store.saveTask(task);
101
- await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'task.orchestrator_rescued', 'queued', `Stale orchestrator task ${task.id} converted to ChatGPT execution`, 'orchestrator', { ageMs: rescue.ageMs, adapter: 'chatgpt' }, { agent: 'relay', component: 'agents-relay' }));
102
- }
96
+ await repairLegacyOrchestrators(jobId, now, { store: this.store, owner: this.options.owner, emit: event => this.emit(event) });
103
97
  await this.recoverPlannerChildren(job);
104
98
  await retryPendingPlannerUnblocks({
105
99
  store: this.store,
@@ -4,6 +4,7 @@ import { mkdirSync } from 'node:fs';
4
4
  import { readdir } from 'node:fs/promises';
5
5
  import { recordPublicationInDatabase, loadPublicationFromDatabase, listPublicationEventsFromDatabase } from './publication-store.js';
6
6
  import { observability } from './observability.js';
7
+ import { migrateLegacyJobPrNumbers, normalizeLegacyJobPrNumber } from './job-pr-number-migration.js';
7
8
  import { ProjectionOutbox, SCHEMA_VERSION, logicalOperationKey, } from './projection-outbox.js';
8
9
  function newerRecord(local, remote) {
9
10
  if (!local)
@@ -99,6 +100,7 @@ export class SQLiteRelayStore {
99
100
  ${ProjectionOutbox.createTableSql()}
100
101
  ${ProjectionOutbox.createIndexSql()}
101
102
  `);
103
+ migrateLegacyJobPrNumbers(this.db);
102
104
  const current = this.db.prepare("SELECT value FROM relay_meta WHERE key='schema_version'").get();
103
105
  const version = current?.value ? Number(current.value) : 0;
104
106
  if (version > SCHEMA_VERSION)
@@ -119,11 +121,11 @@ export class SQLiteRelayStore {
119
121
  }
120
122
  async loadJob(jobId) {
121
123
  const row = this.db.prepare('SELECT json FROM job_snapshot WHERE job_id=?').get(jobId);
122
- return row?.json ? JSON.parse(row.json) : null;
124
+ return row?.json ? normalizeLegacyJobPrNumber(JSON.parse(row.json)) : null;
123
125
  }
124
126
  async listJobs() {
125
127
  const rows = this.db.prepare('SELECT json FROM job_snapshot ORDER BY job_id').all();
126
- return rows.map(row => JSON.parse(row.json));
128
+ return rows.map(row => normalizeLegacyJobPrNumber(JSON.parse(row.json)));
127
129
  }
128
130
  async saveJob(job) {
129
131
  this.writeJobSnapshot(job, new Date().toISOString());
@@ -135,7 +137,7 @@ export class SQLiteRelayStore {
135
137
  const row = this.db.prepare('SELECT json FROM job_snapshot WHERE job_id=?').get(jobId);
136
138
  if (!row?.json)
137
139
  throw new Error(`Local authoritative Job ${jobId} not found`);
138
- const current = JSON.parse(row.json);
140
+ const current = normalizeLegacyJobPrNumber(JSON.parse(row.json));
139
141
  const before = JSON.stringify(current);
140
142
  const next = mutation(current);
141
143
  if (next.id !== jobId)
@@ -388,7 +390,7 @@ export async function discoverLocalRelayStoreSnapshots(directory) {
388
390
  try {
389
391
  db = new DatabaseSync(path, { readOnly: true });
390
392
  const rows = db.prepare('SELECT json FROM job_snapshot ORDER BY job_id').all();
391
- const jobs = rows.map(row => JSON.parse(row.json));
393
+ const jobs = rows.map(row => normalizeLegacyJobPrNumber(JSON.parse(row.json)));
392
394
  const row = db.prepare('SELECT state,head_ref,head_sha,observed_at FROM pull_request_facts WHERE id=1').get();
393
395
  const facts = row ? {
394
396
  state: row.state,
@@ -1,4 +1,5 @@
1
1
  import { normalizeThreadId } from './reviewer-thread.js';
2
+ import { supersedeCancelledTask } from './task-actions.js';
2
3
  export function createWorkspaceControl(deps) {
3
4
  const { client, trusted, noGitHub, repositories, refreshAll, requireLocalJobBinding, makeReconciler, concurrency, pool, bus, isEffectivelyBlocked, unblockAutonomousTasks, unblockTask, publishTaskUnblocked, dispatchTaskAction, registerBlockerDependency, discoverAgents, ensureManagedGitHubJob } = deps;
4
5
  const workspaceControl = async (body) => {
@@ -106,6 +107,15 @@ export function createWorkspaceControl(deps) {
106
107
  await pool.reconcileLocalJob(jobId, 'control.job.unblock');
107
108
  return await store.load(jobId);
108
109
  }
110
+ if (command === 'task.supersede') {
111
+ const taskId = typeof body.taskId === 'string' ? body.taskId : '';
112
+ const reason = typeof body.reason === 'string' ? body.reason : '';
113
+ if (!taskId || !reason.trim())
114
+ throw new Error('task supersede requires taskId and reason');
115
+ await supersedeCancelledTask(store, jobId, taskId, reason);
116
+ await pool.reconcileLocalJob(jobId, 'control.task.supersede');
117
+ return await store.load(jobId);
118
+ }
109
119
  if (command === 'task.recover') {
110
120
  const taskId = typeof body.taskId === 'string' ? body.taskId : '';
111
121
  const recoveryTaskId = typeof body.recoveryTaskId === 'string' ? body.recoveryTaskId : '';
@@ -3,11 +3,11 @@ import { allocateEntityId, isGeneratedEntityId } from './job-task-id-sequence.js
3
3
  import { relayStoreDirectory } from './relay-store.js';
4
4
  import { dispatchJobLifecycleSync } from './workflow/lifecycle.js';
5
5
  export function createDashboardActions(deps) {
6
- const { getOverviews, requireLocalJobBinding, pool, dashboardJobAction, makeReconciler, concurrency, dashboardJobSetState, refreshAll, applyJobActivateAction, applyOrchestratorStartAction, applyOrchestratorCompleteAction, publishOrchestratorTaskStarted, publishOrchestratorTaskCompletion, bus, dashboardTaskAction, dashboardTaskSetState, storeDirectory } = deps;
6
+ const { getOverviews, requireLocalJobBinding, pool, dashboardJobAction, makeReconciler, concurrency, dashboardJobSetState, refreshAll, applyJobActivateAction, bus, dashboardTaskAction, dashboardTaskSetState, storeDirectory } = deps;
7
7
  const currentOverviews = () => getOverviews();
8
8
  const dashboardOverview = (jobId, target) => currentOverviews().find((item) => item.job.id === jobId
9
9
  && (!target?.repository || item.job.repository === target.repository)
10
- && (!target?.prNumber || (item.job.externalPrNumber ?? item.job.prNumber) === target.prNumber));
10
+ && (!target?.prNumber || (item.job.prNumber) === target.prNumber));
11
11
  const workspaceJobAction = (action) => async (jobId) => {
12
12
  const overview = currentOverviews().find((item) => item.job.id === jobId);
13
13
  if (!overview)
@@ -84,36 +84,6 @@ export function createDashboardActions(deps) {
84
84
  await pool.reconcileLocalJob(jobId, 'control.task.create');
85
85
  return { ...(await store.load(jobId)), createdTaskId: task.id };
86
86
  };
87
- const workspaceTaskStart = async (jobId, taskId) => {
88
- const overview = currentOverviews().find((item) => item.job.id === jobId);
89
- if (!overview)
90
- throw new Error(`Managed job ${jobId} not found`);
91
- const store = (await requireLocalJobBinding(jobId)).store;
92
- const job = await store.load(jobId);
93
- const task = job.tasks.find((item) => item.id === taskId);
94
- if (!task)
95
- throw new Error(`Task ${taskId} not found`);
96
- await applyOrchestratorStartAction(task);
97
- await store.saveTask(task);
98
- await publishOrchestratorTaskStarted(bus, job, task);
99
- await pool.reconcileLocalJob(jobId, 'control.task.start');
100
- return await store.load(jobId);
101
- };
102
- const workspaceTaskComplete = async (jobId, taskId, summary) => {
103
- const overview = currentOverviews().find((item) => item.job.id === jobId);
104
- if (!overview)
105
- throw new Error(`Managed job ${jobId} not found`);
106
- const store = (await requireLocalJobBinding(jobId)).store;
107
- const job = await store.load(jobId);
108
- const task = job.tasks.find((item) => item.id === taskId);
109
- if (!task)
110
- throw new Error(`Task ${taskId} not found`);
111
- await applyOrchestratorCompleteAction(task, summary);
112
- await store.saveTask(task);
113
- await publishOrchestratorTaskCompletion(bus, job, task, summary);
114
- await pool.reconcileLocalJob(jobId, 'control.task.complete');
115
- return await store.load(jobId);
116
- };
117
87
  const workspaceTaskAction = (action) => async (jobId, taskId) => {
118
88
  const overview = currentOverviews().find((item) => item.job.id === jobId);
119
89
  if (!overview)
@@ -130,5 +100,5 @@ export function createDashboardActions(deps) {
130
100
  await refreshAll();
131
101
  return result;
132
102
  };
133
- return { dashboardOverview, workspaceJobAction, workspaceJobSetState, workspaceTaskCreate, workspaceTaskStart, workspaceTaskComplete, workspaceTaskAction, workspaceTaskSetState };
103
+ return { dashboardOverview, workspaceJobAction, workspaceJobSetState, workspaceTaskCreate, workspaceTaskAction, workspaceTaskSetState };
134
104
  }
package/dist/relayd.js CHANGED
@@ -24,14 +24,13 @@ import { githubAuthContext } from './github-auth.js';
24
24
  import { DisabledGitHubClient, noGitHubMode } from './no-github.js';
25
25
  import { basename } from 'node:path';
26
26
  import { dashboardJobAction, isEntrypoint, runtimePlanner, runtimeReviewer, startService, SERVICE_DEFAULTS } from './cli.js';
27
- import { eventBusResolutionMessage, publishOrchestratorTaskStarted, publishOrchestratorTaskCompletion, publishTaskUnblocked, resolveEventBus } from './cli-support.js';
27
+ import { eventBusResolutionMessage, publishTaskUnblocked, resolveEventBus } from './cli-support.js';
28
28
  import { eventFor } from './events.js';
29
29
  import { dispatchTaskAction } from './task-actions.js';
30
30
  import { isEffectivelyBlocked, unblockAutonomousTasks, unblockTask } from './unblock.js';
31
31
  import { registerBlockerDependency } from './blockers.js';
32
32
  import { ensureManagedGitHubJob } from './cli-job-create.js';
33
33
  import { applyJobActivateAction } from './job-actions.js';
34
- import { applyOrchestratorStartAction, applyOrchestratorCompleteAction } from './task-actions.js';
35
34
  import { managedTaskUpdateHandler } from './task-update.js';
36
35
  import { dashboardJobSetState, dashboardTaskAction, dashboardTaskSetState } from './dashboard-actions.js';
37
36
  import { codexAndZaiUsageRegistry } from './usage.js';
@@ -155,7 +154,7 @@ export async function runDaemon(argv) {
155
154
  cachedOverviews = await inventoryFromLocal();
156
155
  const makeReconciler = (store, maxConcurrent) => new Reconciler(store, { noGitHub, owner: `relayd-${process.pid}`, maxConcurrent, leaseMs: Number(value(argv, '--lease-ms', '300000')), adapters: [new CodexAdapter(value(argv, '--codex', 'codex')), new ChatGptAdapter()], continuations: [new CodexThreadContinuation(value(argv, '--codex', 'codex')), new CommandContinuation(), new WebhookContinuation()], planner: runtimePlanner(argv), usageSnapshotProvider: () => usageRegistry.snapshot(), runtimeSummaryProvider: () => ({ chatgptRunning: summarizeRuntime(cachedOverviews.map(item => item.job)).workers.chatgpt }), decisionEngine: executionDecisionEngine(), decisionMode: decisionMode(), decisionAuditStore, eventBus: bus, reviewer: runtimeReviewer(argv), projectPathForJob: async (job) => {
157
156
  const canonical = repositoryPaths.get(job.repository) ?? await resolveManagedProjectPath(job.repository, explicitProjectPath, workspaceRoot);
158
- if (job.prNumber !== 0 || job.schedule)
157
+ if (!job.localMerge?.sourceRef || job.schedule)
159
158
  return resolveRepositoryProjectPath(canonical, job.projectPath);
160
159
  const sourceRef = await ensureLocalJobSourceBranch(canonical, job);
161
160
  if (!store.mutateJob)
@@ -243,15 +242,15 @@ export async function runDaemon(argv) {
243
242
  // A completed local Job may never have had a display PR. Publish main
244
243
  // regardless: GitHub availability cannot determine SQLite completion.
245
244
  if (discovered.state === 'COMPLETED' && discovered.localMerge?.status === 'succeeded'
246
- && (discovered.externalPrNumber ?? discovered.prNumber) <= 0) {
245
+ && discovered.prNumber <= 0) {
247
246
  const published = projectAuthoritativeLocalMerge(canonical, discovered);
248
247
  if (published.status === 'projected')
249
248
  await bus?.publish(eventFor(discovered.id, 'projection', null, 'projection.delivered', 'succeeded', `GitHub main synchronization ${published.status}`, 'orchestrator', { operation: 'github.syncMain', deliveryStatus: published.status, mergedCommitSha: published.localMergedCommitSha }, { agent: 'relay', component: 'github-projection' }));
250
249
  continue;
251
250
  }
252
- const existingExternalPr = discovered.externalPrNumber ?? (discovered.prNumber > 0 ? discovered.prNumber : 0);
251
+ const existingPrNumber = discovered.prNumber > 0 ? discovered.prNumber : 0;
253
252
  const terminal = ['COMPLETED', 'CANCELLED'].includes(discovered.state);
254
- if (terminal && existingExternalPr <= 0)
253
+ if (terminal && existingPrNumber <= 0)
255
254
  continue;
256
255
  const sourceSha = await gitText(canonical, ['rev-parse', '--verify', sourceRef]).catch(() => '');
257
256
  if (!sourceSha)
@@ -275,10 +274,10 @@ export async function runDaemon(argv) {
275
274
  }));
276
275
  }
277
276
  let job = (await relay.loadJob(discovered.id)) ?? discovered;
278
- let externalPrNumber = job.externalPrNumber ?? (job.prNumber > 0 ? job.prNumber : 0);
279
- if (externalPrNumber === 0 && ['COMPLETED', 'CANCELLED'].includes(job.state))
277
+ let prNumber = job.prNumber > 0 ? job.prNumber : 0;
278
+ if (prNumber === 0 && ['COMPLETED', 'CANCELLED'].includes(job.state))
280
279
  continue;
281
- if (externalPrNumber === 0) {
280
+ if (prNumber === 0) {
282
281
  // Do not bootstrap an empty remote-only PR commit: it would diverge from
283
282
  // the real local Job branch before its first Worker commit. Publish the
284
283
  // review-only PR when there is an actual diff against main.
@@ -295,17 +294,17 @@ export async function runDaemon(argv) {
295
294
  const pr = await resolvePullRequest(client, job.repository, { head: branch, base, title: job.title, body: job.objective ?? job.description ?? '' });
296
295
  if (!relay.mutateJob)
297
296
  throw new Error('Atomic SQLite mutation is required for GitHub PR binding');
298
- job = await relay.mutateJob(job.id, current => ({ ...current, externalPrNumber: pr.number, updatedAt: new Date().toISOString() }));
299
- externalPrNumber = pr.number;
297
+ job = await relay.mutateJob(job.id, current => ({ ...current, prNumber: pr.number, updatedAt: new Date().toISOString() }));
298
+ prNumber = pr.number;
300
299
  observability().record({ stage: 'projection', event: 'github.pr_bound', outcome: 'succeeded', message: `Bound local Job ${job.id} to GitHub PR #${pr.number}`, correlation: { repository: job.repository, prNumber: pr.number, jobId: job.id }, data: { head: branch, base } });
301
300
  }
302
- if (externalPrNumber <= 0)
301
+ if (prNumber <= 0)
303
302
  continue;
304
303
  const latestFacts = await relay.loadPullRequestFacts();
305
304
  if (latestFacts?.state !== 'MERGED' && (latestFacts?.head?.sha !== sourceSha || latestFacts?.head?.ref !== branch)) {
306
305
  await relay.savePullRequestFacts({ state: latestFacts?.state === 'CLOSED' ? 'CLOSED' : 'OPEN', head: { ref: branch, sha: sourceSha }, observedAt: new Date().toISOString() });
307
306
  }
308
- const github = new GitHubStore(client, job.repository, externalPrNumber, trusted);
307
+ const github = new GitHubStore(client, job.repository, prNumber, trusted);
309
308
  projectionWriters.set(`job:${job.id}`, new GitHubProjectionWriter(relay, github, undefined, true));
310
309
  if (job.state === 'COMPLETED' && job.localMerge?.status === 'succeeded' && job.localMerge.projectionStrategy === 'target_ref_push') {
311
310
  const injection = await consumeMethodInjection(relay, job.repository, job.id, 'github.projectLocalMerge');
@@ -314,14 +313,14 @@ export async function runDaemon(argv) {
314
313
  // A review-only PR is never merged by GitHub. Close it after local main
315
314
  // has been safely published, retaining the diff, comments and commit link.
316
315
  const remotePr = await github.pullRequestState();
317
- await archiveDisplayPullRequest(client, job.repository, externalPrNumber, job.id, projectedMerge.projectedCommitSha, remotePr);
316
+ await archiveDisplayPullRequest(client, job.repository, prNumber, job.id, projectedMerge.projectedCommitSha, remotePr);
318
317
  const projectedState = await github.pullRequestState();
319
318
  const projectedHead = await github.pullRequestHead();
320
319
  await relay.savePullRequestFacts({ state: projectedState, head: projectedHead, observedAt: new Date().toISOString() });
321
320
  observability().record({
322
321
  stage: 'projection', event: 'github.local_merge_projected', outcome: 'succeeded',
323
322
  message: `Projected authoritative local merge ${projectedMerge.localMergedCommitSha} to ${projectedMerge.targetRef}`,
324
- correlation: { repository: job.repository, prNumber: externalPrNumber, jobId: job.id },
323
+ correlation: { repository: job.repository, prNumber, jobId: job.id },
325
324
  data: { ...projectedMerge, githubState: projectedState },
326
325
  });
327
326
  }
@@ -334,7 +333,7 @@ export async function runDaemon(argv) {
334
333
  observability().record({
335
334
  stage: 'projection', event: 'github.binding_job_failed', level: 'warn', outcome: 'failed',
336
335
  message: `GitHub binding/projection failed for ${discovered.id}: ${reason}`,
337
- correlation: { repository: discovered.repository, prNumber: discovered.externalPrNumber ?? discovered.prNumber, jobId: discovered.id },
336
+ correlation: { repository: discovered.repository, prNumber: discovered.prNumber, jobId: discovered.id },
338
337
  data: { classification, reason },
339
338
  });
340
339
  }
@@ -362,7 +361,7 @@ export async function runDaemon(argv) {
362
361
  const initial = cachedOverviews;
363
362
  const fallback = initial[0]?.job;
364
363
  const dashboardStore = fallback ? (await requireLocalJobBinding(fallback.id)).store : new InMemoryStore();
365
- const { dashboardOverview, workspaceJobAction, workspaceJobSetState, workspaceTaskCreate, workspaceTaskStart, workspaceTaskComplete, workspaceTaskAction, workspaceTaskSetState } = createDashboardActions({ getOverviews: () => cachedOverviews, requireLocalJobBinding, pool, dashboardJobAction, makeReconciler, concurrency, dashboardJobSetState, refreshAll, applyJobActivateAction, applyOrchestratorStartAction, applyOrchestratorCompleteAction, publishOrchestratorTaskStarted, publishOrchestratorTaskCompletion, bus, dashboardTaskAction, dashboardTaskSetState });
364
+ const { dashboardOverview, workspaceJobAction, workspaceJobSetState, workspaceTaskCreate, workspaceTaskAction, workspaceTaskSetState } = createDashboardActions({ getOverviews: () => cachedOverviews, requireLocalJobBinding, pool, dashboardJobAction, makeReconciler, concurrency, dashboardJobSetState, refreshAll, applyJobActivateAction, bus, dashboardTaskAction, dashboardTaskSetState });
366
365
  const workspaceControl = createWorkspaceControl({ client, trusted, noGitHub, repositories, refreshAll, requireLocalJobBinding, makeReconciler, concurrency, pool, bus, isEffectivelyBlocked, unblockAutonomousTasks, unblockTask, publishTaskUnblocked, dispatchTaskAction, registerBlockerDependency, discoverAgents, ensureManagedGitHubJob });
367
366
  const secretFile = value(argv, '--github-webhook-secret-file');
368
367
  const secret = noGitHub ? '' : value(argv, '--github-webhook-secret') || process.env.AGENTS_RELAY_GITHUB_WEBHOOK_SECRET || (secretFile ? (await readFile(secretFile, 'utf8')).trim() : '');
@@ -370,7 +369,7 @@ export async function runDaemon(argv) {
370
369
  secret,
371
370
  repositories: new Set(repositories),
372
371
  wake: async (event) => {
373
- const targets = cachedOverviews.filter(item => item.job.repository === event.repository && (event.jobId ? item.job.id === event.jobId : (item.job.externalPrNumber ?? item.job.prNumber) === event.pullRequest));
372
+ const targets = cachedOverviews.filter(item => item.job.repository === event.repository && (event.jobId ? item.job.id === event.jobId : item.job.prNumber === event.pullRequest));
374
373
  if (targets.length === 0)
375
374
  return;
376
375
  for (const target of targets) {
@@ -429,10 +428,8 @@ export async function runDaemon(argv) {
429
428
  const binding = await requireLocalJobBinding(jobId);
430
429
  return { store: binding.store, repository: binding.job.repository, prNumber: binding.job.prNumber };
431
430
  }, async (_repository, _prNumber, jobId) => pool.reconcileLocalJob(jobId, 'control.task.update')),
432
- taskStart: workspaceTaskStart,
433
431
  taskRetry: workspaceTaskAction('retry'),
434
432
  taskCancel: workspaceTaskAction('cancel'),
435
- taskComplete: workspaceTaskComplete,
436
433
  taskSetState: workspaceTaskSetState,
437
434
  }, async ({ jobId, taskId }) => {
438
435
  const overview = cachedOverviews.find(item => item.job.id === jobId);
@@ -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
+ }
@@ -42,7 +42,7 @@ export function prepareReviewerThreadDelivery(job, task, override) {
42
42
  return {
43
43
  kind: 'new-turn', threadId: destination.threadId, threadSource: destination.source,
44
44
  requestId: existing?.requestId ?? `${job.id}:${task.id}:${task.reviewHeadSha ?? job.headSha ?? 'review'}`,
45
- jobId: job.id, prNumber: job.externalPrNumber && job.externalPrNumber > 0 ? job.externalPrNumber : job.prNumber,
45
+ jobId: job.id, prNumber: job.prNumber,
46
46
  ...(task.reviewHeadSha ? { reviewHeadSha: task.reviewHeadSha } : {}),
47
47
  projectId: task.routing?.projectId ?? null,
48
48
  ...(existing?.executionId ? { executionId: existing.executionId } : {}),
@@ -1,5 +1,6 @@
1
1
  function isSuperseded(task) {
2
- return task.state === 'CANCELLED' && typeof task.error === 'string' && task.error.startsWith('Superseded ');
2
+ return task.state === 'CANCELLED' && ((task.resolution?.path === 'cancel_supersede' && task.resolution.state === 'resolved')
3
+ || (typeof task.error === 'string' && task.error.startsWith('Superseded ')));
3
4
  }
4
5
  export function implementationTasks(job) {
5
6
  return job.tasks.filter(task => task.kind !== 'planner'