agents-relay 1.0.124 → 1.0.126

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.
package/dist/cli-help.js CHANGED
@@ -1,4 +1,4 @@
1
- export const usage = 'agents-relay <job|task|blocker|context|init|record|agent-register|agent-discover|reconcile|tick|serve> [options]';
1
+ export const usage = 'agents-relay <job|task|blocker|context|init|record|agent-register|agent-discover|status|reconcile|tick|serve> [options]';
2
2
  const help = {
3
3
  root: `Agents Relay coordinates durable asynchronous agent jobs through GitHub pull requests.
4
4
 
@@ -14,6 +14,7 @@ Commands:
14
14
  record Backfill a completed task into durable history
15
15
  agent-register Register an agent role and its capabilities
16
16
  agent-discover Find registered agents matching hard filters
17
+ status Show live aggregate runtime status from agents-relayd
17
18
  reconcile Run reconciliation once
18
19
  tick Run the indexed autonomous scheduler heartbeat
19
20
  serve Run the dashboard and reconciliation service
@@ -75,6 +76,12 @@ Required: --repo OWNER/REPO, --pr NUMBER, and --id JOB_ID. The current PR head S
75
76
  'job cancel': `Cancel the durable Job and close its backing pull request.
76
77
 
77
78
  Required: --repo OWNER/REPO, --pr NUMBER, and --id JOB_ID. Nonterminal Tasks are cancelled before the PR is closed.`,
79
+ status: `Show live aggregate runtime status from the running Agents Relay service.
80
+
81
+ Usage:
82
+ npx agents-relay status [--host HOST] [--port PORT]
83
+
84
+ Returns the same summary used by the dashboard header plus a running array with concrete RUNNING task and execution identities. Defaults to 127.0.0.1:8787.`,
78
85
  task: `Manage durable tasks.
79
86
 
80
87
  Usage:
package/dist/cli.js CHANGED
@@ -601,6 +601,23 @@ async function main() {
601
601
  return;
602
602
  }
603
603
  const bus = eventBus(args);
604
+ if (command === 'status') {
605
+ const host = arg(args, '--host', '127.0.0.1');
606
+ const port = Number(arg(args, '--port', String(SERVICE_DEFAULTS.port)));
607
+ if (!Number.isInteger(port) || port <= 0 || port > 65535)
608
+ throw new Error('--port must be an integer between 1 and 65535');
609
+ let response;
610
+ try {
611
+ response = await fetch(`http://${host}:${port}/api/runtime-summary`);
612
+ }
613
+ catch (error) {
614
+ throw new Error(`Agents Relay service unavailable at ${host}:${port}: ${error instanceof Error ? error.message : String(error)}`);
615
+ }
616
+ if (!response.ok)
617
+ throw new Error(`Agents Relay status request failed: HTTP ${response.status}`);
618
+ console.log(JSON.stringify(await response.json(), null, 2));
619
+ return;
620
+ }
604
621
  if (command === 'tick') {
605
622
  const repo = repository(args);
606
623
  const auth = await githubAuthContext(args);
@@ -649,10 +666,6 @@ async function main() {
649
666
  console.log(`Recorded ${id}`);
650
667
  return;
651
668
  }
652
- if (command === 'status') {
653
- console.log(JSON.stringify(await store.load(job.id), null, 2));
654
- return;
655
- }
656
669
  if (command === 'reconcile' && !localFile) {
657
670
  try {
658
671
  await requestManagedReconciliation(bus, job);
package/dist/dashboard.js CHANGED
@@ -352,8 +352,15 @@ export function serveDashboard(store, port = 8787, bus, refreshMs = 300000, jobI
352
352
  return;
353
353
  }
354
354
  if (req.url === '/api/runtime-summary') {
355
- res.writeHead(200, { 'content-type': 'application/json' });
356
- res.end(JSON.stringify(summarizeRuntime(lastKnownJobs ?? [])));
355
+ try {
356
+ const jobs = await discoveredJobs();
357
+ res.writeHead(200, { 'content-type': 'application/json' });
358
+ res.end(JSON.stringify(summarizeRuntime(jobs)));
359
+ }
360
+ catch (error) {
361
+ res.writeHead(502);
362
+ res.end(error instanceof Error ? error.message : String(error));
363
+ }
357
364
  return;
358
365
  }
359
366
  if (req.url === '/api/status') {
@@ -1,6 +1,6 @@
1
1
  import { storySizeTimeoutMs, taskOverallTimeoutMs } from './types.js';
2
2
  import { schedule, transitionTaskEvent, transitionJobEvent } from './scheduler.js';
3
- import { implementationComplete } from './runtime-status.js';
3
+ import { implementationComplete, implementationTasks } from './runtime-status.js';
4
4
  import { eventFor } from './events.js';
5
5
  import { selectReviewer } from './reviewer-routing.js';
6
6
  import { routeWorker, workerCandidates } from './worker-routing.js';
@@ -26,8 +26,6 @@ export class ReconcilerReviewBase extends ReconcilerRuntimeBase {
26
26
  async ensureFinalReview(job, prState, headSha) {
27
27
  if (job.schedule || job.reviewEnabled === false || prState !== 'OPEN' || job.state !== 'ACTIVE' || !implementationComplete(job) || !headSha)
28
28
  return;
29
- if (!this.preReviewGatesPass(job))
30
- return;
31
29
  const review = job.review;
32
30
  if (review?.headSha === headSha && ['started', 'approved', 'changes_required', 'blocked'].includes(review.outcome))
33
31
  return;
@@ -38,9 +36,9 @@ export class ReconcilerReviewBase extends ReconcilerRuntimeBase {
38
36
  return;
39
37
  const id = `final-review-${headSha.slice(0, 12)}`;
40
38
  const now = new Date().toISOString();
41
- const implementationTasks = job.tasks.filter(item => item.agentName !== 'reviewer' && !this.isSupersededTask(item));
42
- const reviewerRouting = await this.selectReviewer(implementationTasks);
43
- const task = { jobId: job.id, id, kind: 'work', priority: job.priority, storySize: 'M', projectName: job.title, agentName: 'reviewer', parentTaskId: null, dependencies: implementationTasks.map(item => item.id), capabilities: ['review'], workerSelection: reviewerRouting.selection, adapter: reviewerRouting.candidate.adapter, input: `Review existing PR #${job.prNumber} at exactly head SHA ${headSha}. Job objective: ${job.objective ?? job.description ?? job.title}. Use the selected reviewer agent contract as the authoritative review protocol.`, output: { kind: 'task_pr' }, routing: reviewerRouting.candidate.routing, continuation: null, continuationDeliveredAt: null, reviewHeadSha: headSha, state: 'QUEUED', attempt: 0, maxAttempts: this.options.reviewer?.maxAttempts ?? 2, leaseOwner: null, leaseExpiresAt: null, executionId: null, threadId: null, result: null, plannerResult: null, error: null, timeoutMs: this.options.reviewer?.timeoutMs ?? storySizeTimeoutMs('M'), createdAt: now, updatedAt: now };
39
+ const reviewGateTasks = implementationTasks(job);
40
+ const reviewerRouting = await this.selectReviewer(reviewGateTasks);
41
+ const task = { jobId: job.id, id, kind: 'work', priority: job.priority, storySize: 'M', projectName: job.title, agentName: 'reviewer', parentTaskId: null, dependencies: reviewGateTasks.map(item => item.id), capabilities: ['review'], workerSelection: reviewerRouting.selection, adapter: reviewerRouting.candidate.adapter, input: `Review existing PR #${job.prNumber} at exactly head SHA ${headSha}. Job objective: ${job.objective ?? job.description ?? job.title}. Use the selected reviewer agent contract as the authoritative review protocol.`, output: { kind: 'task_pr' }, routing: reviewerRouting.candidate.routing, continuation: null, continuationDeliveredAt: null, reviewHeadSha: headSha, state: 'QUEUED', attempt: 0, maxAttempts: this.options.reviewer?.maxAttempts ?? 2, leaseOwner: null, leaseExpiresAt: null, executionId: null, threadId: null, result: null, plannerResult: null, error: null, timeoutMs: this.options.reviewer?.timeoutMs ?? storySizeTimeoutMs('M'), createdAt: now, updatedAt: now };
44
42
  await this.store.appendTask(task);
45
43
  job.review = { outcome: 'pending', headSha, taskId: id, evidence: null, updatedAt: now };
46
44
  job.updatedAt = now;
@@ -145,12 +143,6 @@ export class ReconcilerReviewBase extends ReconcilerRuntimeBase {
145
143
  await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'model.selected', 'queued', `Selected ${task.routing.model} · thinking ${task.routing.reasoning ?? 'unknown'}`, 'user', { routing: task.routing }, { agent: 'relay', component: 'agents-relay' }));
146
144
  }
147
145
  }
148
- isSupersededTask(task) {
149
- return task.state === 'CANCELLED' && typeof task.error === 'string' && task.error.startsWith('Superseded ');
150
- }
151
- preReviewGatesPass(job) {
152
- return job.tasks.filter(task => task.agentName !== 'reviewer' && !this.isSupersededTask(task)).every(task => task.state === 'SUCCEEDED');
153
- }
154
146
  async applyReviewEvent(event) {
155
147
  const job = await this.store.load(event.job_id);
156
148
  const task = job.tasks.find(item => item.id === event.task_id && item.agentName === 'reviewer');
@@ -22,7 +22,6 @@ export class ReconcilerRuntimeBase {
22
22
  live = new Map();
23
23
  plannerLive = new Map();
24
24
  terminalTimers = new Map();
25
- terminalExitGraceTimers = new Map();
26
25
  continuationLive = new Set();
27
26
  launchFailureClaims = new Set();
28
27
  terminalWaiters = new Map();
@@ -108,9 +107,6 @@ export class ReconcilerRuntimeBase {
108
107
  for (const timer of this.terminalTimers.values())
109
108
  clearTimeout(timer);
110
109
  this.terminalTimers.clear();
111
- for (const timer of this.terminalExitGraceTimers.values())
112
- clearTimeout(timer);
113
- this.terminalExitGraceTimers.clear();
114
110
  for (const planner of this.plannerLive.values()) {
115
111
  clearTimeout(planner.timer);
116
112
  planner.controller.abort();
@@ -171,10 +167,6 @@ export class ReconcilerRuntimeBase {
171
167
  if (timer)
172
168
  clearTimeout(timer);
173
169
  this.terminalTimers.delete(taskId);
174
- const graceTimer = this.terminalExitGraceTimers.get(taskId);
175
- if (graceTimer)
176
- clearTimeout(graceTimer);
177
- this.terminalExitGraceTimers.delete(taskId);
178
170
  }
179
171
  async markWorkerStarted(event) {
180
172
  const live = this.live.get(event.task_id);
@@ -245,53 +237,13 @@ export class ReconcilerRuntimeBase {
245
237
  }
246
238
  async runtimeSettled(jobId, taskId, executionId, result, error) {
247
239
  const live = this.live.get(taskId);
248
- const liveWorkerStarted = live?.executionId === executionId && live.workerStarted;
249
240
  if (live?.executionId === executionId)
250
241
  live.processSettled = true;
251
242
  const job = await this.store.load(jobId);
252
243
  const task = job.tasks.find(item => item.id === taskId);
253
- const workerStarted = liveWorkerStarted || task?.state === 'RUNNING';
254
244
  if (!task || task.executionId !== executionId)
255
245
  return;
256
246
  await this.emit(eventFor(jobId, taskId, task.parentTaskId, task.adapter === 'chatgpt' ? 'task.process.async_exited' : 'task.process.exited', error ? 'failed' : 'succeeded', error ?? result?.summary ?? 'Worker runtime exited', 'orchestrator', { authoritative: false, waitingForTerminalEvent: true, processError: error }, { agent: 'relay', component: 'agents-relay' }));
257
- if (task.adapter === 'codex' && !terminalExecutionClaimed(jobId, taskId, executionId))
258
- this.scheduleUnexpectedExit(jobId, taskId, executionId, workerStarted === true);
259
- }
260
- scheduleUnexpectedExit(jobId, taskId, executionId, workerStarted) {
261
- const graceMs = Math.max(1, this.options.terminalExitGraceMs ?? 1000);
262
- const timer = setTimeout(() => {
263
- this.terminalExitGraceTimers.delete(taskId);
264
- this.detach(this.failUnexpectedExit(jobId, taskId, executionId, workerStarted), jobId, taskId, 'unexpected worker process exit');
265
- }, graceMs);
266
- this.terminalExitGraceTimers.set(taskId, timer);
267
- }
268
- async failUnexpectedExit(jobId, taskId, executionId, workerStarted) {
269
- if (terminalExecutionClaimed(jobId, taskId, executionId))
270
- return;
271
- const current = await this.store.load(jobId);
272
- const currentTask = current.tasks.find(item => item.id === taskId);
273
- if (!currentTask || currentTask.executionId !== executionId || ['SUCCEEDED', 'FAILED', 'BLOCKED', 'CANCELLED'].includes(currentTask.state)) {
274
- this.clearExecution(taskId, executionId);
275
- return;
276
- }
277
- const reason = workerStarted ? 'worker_exit_without_terminal_event' : 'worker_exit_before_start';
278
- const message = workerStarted
279
- ? `Worker ${taskId} exited after task.started without publishing a terminal task event`
280
- : `Worker ${taskId} exited before publishing task.started`;
281
- const transition = await this.taskTransitions.apply(jobId, taskId, 'watchdog.failed', {
282
- eligible: (_job, task) => task.executionId === executionId && !terminalExecutionClaimed(jobId, taskId, executionId),
283
- mutate: (_job, task) => {
284
- this.clearExecution(task.id, executionId);
285
- task.error = message;
286
- task.failureReason = reason;
287
- task.leaseOwner = null;
288
- task.leaseExpiresAt = null;
289
- },
290
- });
291
- if (!transition?.decision.accepted)
292
- return;
293
- const event = eventFor(jobId, taskId, transition.task.parentTaskId, 'task.failed', 'failed', message, 'user', { reason, terminalGraceMs: this.options.terminalExitGraceMs ?? 1000, workflowEvent: 'watchdog.failed' }, { agent: 'relay', component: 'agents-relay', watchdog: true });
294
- this.detach(this.emit(event), jobId, taskId, 'watchdog failure event');
295
247
  }
296
248
  async timeoutTask(jobId, taskId, executionId) {
297
249
  const live = this.live.get(taskId);
@@ -2,7 +2,7 @@ function isSuperseded(task) {
2
2
  return task.state === 'CANCELLED' && typeof task.error === 'string' && task.error.startsWith('Superseded ');
3
3
  }
4
4
  export function implementationTasks(job) {
5
- return job.tasks.filter(task => task.agentName !== 'reviewer' && !isSuperseded(task));
5
+ return job.tasks.filter(task => task.agentName !== 'reviewer' && task.agentName !== 'troubleshooter' && !isSuperseded(task));
6
6
  }
7
7
  export function implementationComplete(job) {
8
8
  const tasks = implementationTasks(job);
@@ -10,6 +10,7 @@ export function summarizeRuntime(jobs) {
10
10
  let chatgpt = 0;
11
11
  let codex = 0;
12
12
  const models = new Map();
13
+ const running = [];
13
14
  for (const job of jobs) {
14
15
  if (job.state === 'ACTIVE')
15
16
  activeJobs += 1;
@@ -25,6 +26,19 @@ export function summarizeRuntime(jobs) {
25
26
  const model = task.routing?.model;
26
27
  if (model)
27
28
  models.set(model, (models.get(model) ?? 0) + 1);
29
+ running.push({
30
+ repository: job.repository,
31
+ prNumber: job.prNumber,
32
+ jobId: job.id,
33
+ jobTitle: job.title,
34
+ taskId: task.id,
35
+ agentName: task.agentName ?? null,
36
+ worker: task.workerSelection?.adapter ?? task.adapter,
37
+ provider: task.routing?.provider ?? null,
38
+ model: model ?? null,
39
+ executionId: task.executionId,
40
+ leaseExpiresAt: task.leaseExpiresAt,
41
+ });
28
42
  }
29
43
  }
30
44
  return {
@@ -34,5 +48,6 @@ export function summarizeRuntime(jobs) {
34
48
  models: [...models.entries()]
35
49
  .map(([model, count]) => ({ model, count }))
36
50
  .sort((left, right) => right.count - left.count || left.model.localeCompare(right.model)),
51
+ running,
37
52
  };
38
53
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-relay",
3
- "version": "1.0.124",
3
+ "version": "1.0.126",
4
4
  "description": "Durable async agent jobs coordinated through GitHub pull requests",
5
5
  "type": "module",
6
6
  "bin": {
@@ -51,7 +51,7 @@ Agents Relay maps those terminal events to durable state:
51
51
 
52
52
  A model-backed task must not launch without an event bus. If no terminal event arrives before the task timeout, Relay records an explicit timeout failure. A Codex process exit or ChatGPT submission receipt alone never changes the task to a terminal state.
53
53
 
54
- For Codex, an unexpected process exit after the worker's `task.started` is given only a short bounded terminal grace window. If no exact correlated worker terminal event arrives in that window, Relay records `task.failed` with `worker_exit_without_terminal_event` as an explicit lifecycle-contract failure. This watchdog failure is still distinct from `task.process.exited`; it never treats process exit as task success and never duplicates an already-applied terminal event. An exit before `task.started` is likewise recorded as `worker_exit_before_start`.
54
+ Worker process exit is observational only. Relay does not perform an exit-time terminal check or synthesize task failure from process exit; semantic terminal state comes from the worker event contract or the normal task timeout.
55
55
 
56
56
  ## Managed GitHub jobs
57
57