agents-relay 2.0.58 → 2.0.60

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/relayd.js CHANGED
@@ -17,15 +17,17 @@ import { eventBusResolutionMessage, publishOrchestratorTaskStarted, publishOrche
17
17
  import { eventFor } from './events.js';
18
18
  import { applyJobActivateAction } from './job-actions.js';
19
19
  import { applyOrchestratorStartAction, applyOrchestratorCompleteAction } from './task-actions.js';
20
+ import { managedTaskUpdateHandler } from './task-update.js';
20
21
  import { dashboardJobSetState, dashboardTaskAction, dashboardTaskSetState } from './dashboard-actions.js';
21
22
  import { codexAndZaiUsageRegistry } from './usage.js';
22
23
  import { defaultWorkspaceRoot, discoverWorkspaceRepositories, resolveManagedProjectPath, resolveRepositoryProjectPath } from './workspace.js';
23
24
  import { deriveJobRuntimeStatus } from './runtime-status.js';
24
- import { decisionMode, LayaCliDecisionEngine } from './decision.js';
25
+ import { decisionMode, executionDecisionEngine } from './decision.js';
25
26
  import { JsonlDecisionAuditStore } from './decision-audit.js';
26
27
  import { summarizeRuntime } from './runtime-summary.js';
27
28
  import { executionRouterRulesPath } from './routing-policy.js';
28
29
  import { workerCandidates } from './worker-routing.js';
30
+ import { shutdownOwnedProcesses } from './owned-process.js';
29
31
  import { routeExecution } from './execution-routing.js';
30
32
  import { RelayObservability, classifyFailure, errorMessage, observability, setObservability } from './observability.js';
31
33
  /** Initialize the process-wide structured observability sink once, from environment. */
@@ -196,7 +198,7 @@ export async function runDaemon(argv) {
196
198
  job, githubState: snapshot.facts.state, draft: false, mergedAt: null, closedAt: null, publication: null,
197
199
  }));
198
200
  }));
199
- const makeReconciler = (store, maxConcurrent) => new Reconciler(store, { 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: new LayaCliDecisionEngine(), decisionMode: decisionMode(), decisionAuditStore, eventBus: bus, reviewer: runtimeReviewer(), projectPathForJob: async (job) => resolveRepositoryProjectPath(repositoryPaths.get(job.repository) ?? await resolveManagedProjectPath(job.repository, explicitProjectPath, workspaceRoot), job.projectPath) });
201
+ const makeReconciler = (store, maxConcurrent) => new Reconciler(store, { 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(), projectPathForJob: async (job) => resolveRepositoryProjectPath(repositoryPaths.get(job.repository) ?? await resolveManagedProjectPath(job.repository, explicitProjectPath, workspaceRoot), job.projectPath) });
200
202
  const refreshAll = async (full = false) => {
201
203
  if (client.rateLimitStatus().limited)
202
204
  return cachedOverviews;
@@ -403,6 +405,8 @@ export async function runDaemon(argv) {
403
405
  cancel: workspaceJobAction('cancel'),
404
406
  jobSetState: workspaceJobSetState,
405
407
  taskCreate: workspaceTaskCreate,
408
+ taskUpdate: managedTaskUpdateHandler(jobId => { const row = cachedOverviews.find(item => item.job.id === jobId); if (!row)
409
+ throw new Error('Job not found'); return { store: runtimeStore(row.job.repository, row.job.prNumber), repository: row.job.repository, prNumber: row.job.prNumber }; }, (repository, prNumber, jobId) => pool.reconcileTarget(repository, prNumber, jobId, 'control.task.update')),
406
410
  taskStart: workspaceTaskStart,
407
411
  taskRetry: workspaceTaskAction('retry'),
408
412
  taskCancel: workspaceTaskAction('cancel'),
@@ -423,7 +427,7 @@ export async function runDaemon(argv) {
423
427
  { name: 'chatgpt', id: 'chatgpt/browser-worker', capabilities: ['model', 'chatgpt', 'browser-workspace'] },
424
428
  ], [], usage);
425
429
  const result = await routeExecution(workers, task.workerRequirements ?? { capabilities: task.capabilities ?? [] }, {
426
- decisionEngine: new LayaCliDecisionEngine(),
430
+ decisionEngine: executionDecisionEngine(),
427
431
  decisionMode: 'active',
428
432
  taskFacts: {
429
433
  job: {
@@ -436,7 +440,7 @@ export async function runDaemon(argv) {
436
440
  id: task.id, kind: task.kind ?? 'work', storySize: task.storySize ?? 'M',
437
441
  priority: task.priority ?? null, agentName: task.agentName ?? null,
438
442
  parentTaskId: task.parentTaskId, dependencies: task.dependencies,
439
- capabilities: task.capabilities, input: task.input,
443
+ capabilities: task.capabilities, taskCapabilities: task.taskCapabilities, workerCapabilities: task.workerCapabilities, input: task.input,
440
444
  attachments: task.attachments ?? [], output: task.output ?? null,
441
445
  state: task.state, attempt: task.attempt, maxAttempts: task.maxAttempts,
442
446
  },
@@ -508,7 +512,7 @@ export async function runDaemon(argv) {
508
512
  const stop = () => { if (interval)
509
513
  clearInterval(interval); if (scheduleInterval)
510
514
  clearInterval(scheduleInterval); if (projectionInterval)
511
- clearInterval(projectionInterval); void stopWatch().finally(() => { for (const store of relayStores.values())
515
+ clearInterval(projectionInterval); void Promise.allSettled([stopWatch(), shutdownOwnedProcesses('parent-shutdown')]).finally(() => { for (const store of relayStores.values())
512
516
  store.close(); server.close(() => process.exit(0)); }); };
513
517
  process.once('SIGINT', stop);
514
518
  process.once('SIGTERM', stop);
@@ -0,0 +1,52 @@
1
+ import { applyTaskHoldAction, applyTaskQueueAction } from './task-actions.js';
2
+ const editableFields = [
3
+ 'priority', 'storySize', 'projectName', 'agentName', 'agentGraph',
4
+ 'parentTaskId', 'dependencies', 'capabilities', 'workerRequirements',
5
+ 'adapter', 'chatgptTabClosePolicy', 'input', 'attachments',
6
+ 'routing', 'timeoutMs', 'maxAttempts',
7
+ ];
8
+ /** All managed CLI Task updates commit through the daemon's operational store. */
9
+ export async function applyManagedTaskUpdate(store, jobId, taskId, proposed, expectedUpdatedAt) {
10
+ const job = await store.load(jobId);
11
+ const current = job.tasks.find(item => item.id === taskId);
12
+ if (!current || proposed.id !== taskId || proposed.jobId !== jobId)
13
+ throw new Error('Task update identity mismatch');
14
+ if (current.updatedAt !== expectedUpdatedAt)
15
+ throw new Error('Task changed since update preflight; refresh and retry');
16
+ if (['RUNNING', 'UNBLOCKED', 'SUCCEEDED', 'CANCELLED'].includes(current.state) || current.executionId || current.leaseOwner)
17
+ throw new Error('Task is not mutable while active or terminal');
18
+ if (current.attempt > 0 && current.adapter !== proposed.adapter)
19
+ throw new Error('Cannot replace adapter after an execution attempt');
20
+ if (proposed.maxAttempts < current.attempt)
21
+ throw new Error('Retry budget is below execution attempts');
22
+ const next = structuredClone(current);
23
+ for (const key of editableFields) {
24
+ if (Object.hasOwn(proposed, key))
25
+ next[key] = structuredClone(proposed[key]);
26
+ else
27
+ delete next[key];
28
+ }
29
+ if (next.dependencies.includes(taskId))
30
+ throw new Error('Task cannot depend on itself');
31
+ if (proposed.state !== current.state) {
32
+ if (proposed.state === 'DRAFT' && current.state === 'QUEUED')
33
+ await applyTaskHoldAction(next);
34
+ else if (proposed.state === 'QUEUED')
35
+ await applyTaskQueueAction(job, next);
36
+ else
37
+ throw new Error('Task state change requires a dedicated lifecycle action');
38
+ }
39
+ if (next.adapter !== current.adapter || JSON.stringify(next.routing) !== JSON.stringify(current.routing))
40
+ next.workerSelection = undefined;
41
+ next.updatedAt = new Date().toISOString();
42
+ await store.saveTask(next);
43
+ return store.load(jobId);
44
+ }
45
+ export function managedTaskUpdateHandler(locate, wake) {
46
+ return async (jobId, taskId, task, expectedUpdatedAt) => {
47
+ const target = locate(jobId);
48
+ const updated = await applyManagedTaskUpdate(target.store, jobId, taskId, task, expectedUpdatedAt);
49
+ await wake(target.repository, target.prNumber, jobId);
50
+ return updated;
51
+ };
52
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Replace the overall worker deadline with a shorter post-exit deadline.
3
+ * Execution outcomes remain owned by the authoritative task lifecycle graph.
4
+ * Never extend the original overall timeout.
5
+ */
6
+ export function rearmTerminalEventDeadline(timers, taskId, overallRemainingMs, graceMs, expire) {
7
+ const existing = timers.get(taskId);
8
+ if (existing)
9
+ clearTimeout(existing);
10
+ timers.set(taskId, setTimeout(expire, Math.max(1, Math.min(overallRemainingMs, graceMs))));
11
+ }
package/dist/usage.js CHANGED
@@ -3,7 +3,7 @@ import { readdir, stat } from 'node:fs/promises';
3
3
  import { homedir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
  import { createInterface } from 'node:readline';
6
- import { spawn } from 'node:child_process';
6
+ import { spawnOwnedProcess, terminateOwnedProcess } from './owned-process.js';
7
7
  export class BrowserWorkspaceUsageReader {
8
8
  command;
9
9
  timeoutMs;
@@ -18,7 +18,7 @@ export class BrowserWorkspaceUsageReader {
18
18
  async read() {
19
19
  const observedAt = this.now().toISOString();
20
20
  return await new Promise(resolve => {
21
- const child = spawn(this.command, [], { stdio: ['pipe', 'pipe', 'ignore'] });
21
+ const child = spawnOwnedProcess(this.command, [], { invocation: 'usage.browser-workspace', timeoutMs: this.timeoutMs, spawn: { stdio: ['pipe', 'pipe', 'ignore'] } });
22
22
  let output = '';
23
23
  let settled = false;
24
24
  const finish = (capacity) => {
@@ -26,13 +26,13 @@ export class BrowserWorkspaceUsageReader {
26
26
  return;
27
27
  settled = true;
28
28
  clearTimeout(timer);
29
- child.kill();
29
+ void terminateOwnedProcess(child, 'cancel');
30
30
  resolve(capacity);
31
31
  };
32
32
  const unavailable = (reason) => ({ name: 'Harness', poolSize: null, idle: null, leased: null, available: null, observedAt, reason });
33
33
  const timer = setTimeout(() => finish(unavailable('Browser Workspace capacity probe timed out.')), this.timeoutMs);
34
- child.stdout.setEncoding('utf8');
35
- child.stdout.on('data', chunk => { output += chunk; });
34
+ child.stdout?.setEncoding('utf8');
35
+ child.stdout?.on('data', chunk => { output += chunk; });
36
36
  child.once('error', () => finish(unavailable('Browser Workspace capacity probe could not be started.')));
37
37
  child.once('close', code => {
38
38
  if (settled)
@@ -65,7 +65,7 @@ export class BrowserWorkspaceUsageReader {
65
65
  }
66
66
  finish(unavailable(code === 0 ? 'Browser Workspace capacity probe returned no usable capacity.' : `Browser Workspace capacity probe exited with code ${code ?? 'unknown'}.`));
67
67
  });
68
- child.stdin.end('import json\nprint(json.dumps(workspace_capacity()))\n');
68
+ child.stdin?.end('import json\nprint(json.dumps(workspace_capacity()))\n');
69
69
  });
70
70
  }
71
71
  }
@@ -133,9 +133,15 @@ export class CodexApiUsageReader {
133
133
  async read() {
134
134
  const generatedAt = this.now().toISOString();
135
135
  return await new Promise(resolve => {
136
- const child = spawn('codex', ['app-server', '--stdio'], { stdio: ['pipe', 'pipe', 'ignore'] });
136
+ const child = spawnOwnedProcess('codex', ['app-server', '--stdio'], { invocation: 'usage.codex-app-server', timeoutMs: 10_000, spawn: { stdio: ['pipe', 'pipe', 'ignore'] } });
137
+ if (!child.stdout) {
138
+ resolve({ available: false, generatedAt, fiveHour: null, weekly: null, reason: 'Codex app-server stdout unavailable.' });
139
+ return;
140
+ }
137
141
  const lines = createInterface({ input: child.stdout, crlfDelay: Infinity });
138
- const finish = (snapshot) => { lines.close(); child.kill(); resolve(snapshot); };
142
+ let queryTimer;
143
+ const finish = (snapshot) => { lines.close(); if (queryTimer)
144
+ clearTimeout(queryTimer); void terminateOwnedProcess(child, 'cancel'); resolve(snapshot); };
139
145
  const timer = setTimeout(() => finish({ available: false, generatedAt, fiveHour: null, weekly: null, reason: 'Codex app-server rate-limit request timed out.' }), 10_000);
140
146
  lines.on('line', line => {
141
147
  try {
@@ -164,8 +170,8 @@ export class CodexApiUsageReader {
164
170
  }
165
171
  });
166
172
  child.once('error', () => { clearTimeout(timer); finish({ available: false, generatedAt, fiveHour: null, weekly: null, reason: 'Codex app-server could not be started.' }); });
167
- child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { clientInfo: { name: 'agents-relay', version: '1' }, capabilities: {} } }) + '\n');
168
- setTimeout(() => child.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'account/rateLimits/read', params: {} }) + '\n'), 100);
173
+ child.stdin?.write(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: { clientInfo: { name: 'agents-relay', version: '1' }, capabilities: {} } }) + '\n');
174
+ queryTimer = setTimeout(() => child.stdin?.write(JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'account/rateLimits/read', params: {} }) + '\n'), 100);
169
175
  });
170
176
  }
171
177
  }
package/dist/workspace.js CHANGED
@@ -1,9 +1,18 @@
1
1
  import { mkdir, readdir, stat } from 'node:fs/promises';
2
2
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
- import { execFile } from 'node:child_process';
5
- import { promisify } from 'node:util';
6
- const execFileAsync = promisify(execFile);
4
+ import { spawnOwnedProcess } from './owned-process.js';
5
+ async function gitOutput(args) {
6
+ return await new Promise((resolve, reject) => {
7
+ const child = spawnOwnedProcess('git', args, { invocation: 'workspace.git', spawn: { stdio: ['ignore', 'pipe', 'pipe'] } });
8
+ let stdout = '';
9
+ let stderr = '';
10
+ child.stdout?.on('data', chunk => { stdout += chunk.toString(); });
11
+ child.stderr?.on('data', chunk => { stderr += chunk.toString(); });
12
+ child.once('error', reject);
13
+ child.once('close', code => code === 0 ? resolve(stdout) : reject(new Error(stderr.trim() || `git exited ${code}`)));
14
+ });
15
+ }
7
16
  export function defaultWorkspaceRoot() {
8
17
  return join(homedir(), 'Workspace');
9
18
  }
@@ -42,7 +51,7 @@ export async function discoverGitRepositories(root) {
42
51
  }
43
52
  async function origin(path) {
44
53
  try {
45
- return (await execFileAsync('git', ['-C', path, 'remote', 'get-url', 'origin'])).stdout.trim() || null;
54
+ return (await gitOutput(['-C', path, 'remote', 'get-url', 'origin'])).trim() || null;
46
55
  }
47
56
  catch {
48
57
  return null;
@@ -111,7 +120,7 @@ export function taskUsesCanonicalProjectRun(task, tasks) {
111
120
  return false;
112
121
  }
113
122
  async function git(path, args) {
114
- return (await execFileAsync('git', ['-C', path, ...args])).stdout.trim();
123
+ return (await gitOutput(['-C', path, ...args])).trim();
115
124
  }
116
125
  export async function prepareManagedExecutionWorktree(projectPath, repository, prNumber, head, executionId) {
117
126
  const primary = await git(projectPath, ['rev-parse', '--show-toplevel']);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-relay",
3
- "version": "2.0.58",
3
+ "version": "2.0.60",
4
4
  "description": "Durable async agent jobs coordinated through GitHub pull requests",
5
5
  "type": "module",
6
6
  "bin": {
@@ -120,6 +120,7 @@ queued -->|on: worker.delivery_failed| failed
120
120
  queued -->|on: worker.start_ack_failed| failed
121
121
  queued -->|on: planner.unavailable| plannerUnavailableMark
122
122
  plannerUnavailableMark --> failed
123
+ queued -->|on: execution.orphaned| orphanRetryAvailable
123
124
  queued -->|on: lease.expired| retryAvailableQueued
124
125
  retryAvailableQueued -->|true| leaseRetryQueuedClearError
125
126
  leaseRetryQueuedClearError --> leaseRetryQueuedClearLease