agents-relay 2.0.3 → 2.0.5

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/README.md CHANGED
@@ -160,7 +160,7 @@ reconcile leases and launches ready tasks without waiting inside the runtime sch
160
160
 
161
161
  `agents-relayd` without `--pr`/`--id` runs in workspace mode by default: it discovers nested Git repositories under `~/Workspace`, keeps only GitHub `origin` remotes, and aggregates their managed PR jobs into one dashboard and worker pool. Pass `--workspace PATH` to use another root. `--repo OWNER/REPO` remains the single-repository pool mode.
162
162
 
163
- For automation, Agents Relay can use GitHub App installation authentication via `AGENTS_RELAY_GITHUB_APP_ID`, `AGENTS_RELAY_GITHUB_APP_INSTALLATION_ID`, and `AGENTS_RELAY_GITHUB_APP_PRIVATE_KEY_FILE` (or matching CLI flags). Webhook mode is cache-first: targeted webhook reconciliation is primary, dashboard auto-refresh reads cached job state, and the broad watchdog drops to a 30-minute fallback. See [docs/github-app-rate-limit.md](docs/github-app-rate-limit.md).
163
+ For automation, Agents Relay can use GitHub App installation authentication via `AGENTS_RELAY_GITHUB_APP_ID`, `AGENTS_RELAY_GITHUB_APP_INSTALLATION_ID`, and `AGENTS_RELAY_GITHUB_APP_PRIVATE_KEY_FILE` (or matching CLI flags). Webhook mode is local-first: targeted webhook reconciliation is primary, dashboard auto-refresh reads the disposable in-process view restored from SQLite RelayStore state, and the broad watchdog drops to a 30-minute fallback. No separate persisted dashboard Job cache is maintained. See [docs/github-app-rate-limit.md](docs/github-app-rate-limit.md).
164
164
 
165
165
  V1 assumes one active runner/daemon per job. Use a distributed atomic lease before running multiple reconcilers. Do not put secrets, credentials, private prompts, or large private payloads in the PR-body Job marker or PR-comment Task markers; store only summaries and artifact references.
166
166
 
@@ -397,36 +397,53 @@ function githubBucket(label,bucket){const row=element('div','usage-row');const h
397
397
  function githubCounterLine(label,values){const entries=Object.entries(values||{}).sort((a,b)=>Number(b[1])-Number(a[1])).slice(0,8);return entries.length?element('div','subtle',text(label+' · '+entries.map(([key,count])=>key+' '+count).join(' · '))):null;}
398
398
  function githubObserved(label,observed){
399
399
  if(!observed)return null;
400
+ if(Number.isFinite(observed.limit)&&Number.isFinite(observed.remaining)){const row=githubBucket(label,observed);if(observed.reason)row.title=observed.reason;return row;}
400
401
  const state=observed.throttled?'Throttled':observed.available===true?'Available':observed.available===false?'Unavailable':'Unknown';
401
402
  const parts=[label+' observed',state];
402
403
  if(observed.status!==null)parts.push('status '+observed.status);
403
- if(observed.remaining!==null)parts.push('remaining '+observed.remaining);
404
- if(observed.headersCaptured&&observed.reset!==null)parts.push(shortReset(observed.reset));
405
404
  if(observed.retryAfterSeconds!==null)parts.push('retry '+observed.retryAfterSeconds+'s');
406
405
  const row=element('div',observed.throttled?'usage-row error-state':'usage-row subtle');
407
406
  row.textContent=parts.filter(Boolean).join(' · ');
408
407
  if(observed.reason)row.title=observed.reason;
409
408
  return row;
410
409
  }
410
+ function githubSecondary(status){
411
+ const rejection=status?.runtimeRateUsage?.rejection;
412
+ const row=element('div','usage-row');
413
+ const heading=element('div','usage-label');
414
+ if(rejection?.kind!=='secondary'){heading.append(element('span','',text('Secondary protection')),element('span','',text('No active cooldown')));row.append(heading);return row;}
415
+ const start=Date.parse(rejection.observedAt);
416
+ const until=Date.parse(rejection.cooldownUntil||status?.cooldownUntil||'');
417
+ if(!Number.isFinite(start)||!Number.isFinite(until)||until<=start){heading.append(element('span','',text('Secondary protection')),element('span','',text('Throttled')));row.classList.add('error-state');row.append(heading);return row;}
418
+ const total=until-start;
419
+ const remaining=Math.max(0,until-Date.now());
420
+ const percent=Math.max(0,Math.min(100,100*remaining/total));
421
+ heading.append(element('span','',text('Secondary cooldown')),element('span','',text(Math.ceil(remaining/1000)+'s')));
422
+ const track=element('div','usage-track');track.setAttribute('role','progressbar');track.setAttribute('aria-label','Secondary cooldown remaining');track.setAttribute('aria-valuemin','0');track.setAttribute('aria-valuemax','100');track.setAttribute('aria-valuenow',String(Math.round(percent)));const fill=element('div','usage-fill');fill.style.width=percent+'%';track.append(fill);
423
+ const hint=element('div','subtle',text('Retry '+new Date(until).toLocaleString()));hint.title=new Date(until).toLocaleString();
424
+ row.append(heading,track,hint);
425
+ return row;
426
+ }
411
427
  function githubDiagnostics(status){const body=element('div','usage-details-body');if(status?.calls)body.append(element('div','subtle',text('Calls · GraphQL '+(status.calls.graphql||0)+' · REST '+(status.calls.rest||0)+' · quota '+(status.calls.rateLimit||0))));const operations=githubCounterLine('GraphQL operations',status?.graphql?.operations);if(operations)body.append(operations);const contexts=githubCounterLine('GraphQL contexts',status?.graphql?.contexts);if(contexts)body.append(contexts);if(status?.readPaths){body.append(element('div','subtle',text('Reads · scans '+(status.readPaths.workspaceScans||0)+' · PR '+(status.readPaths.prRefreshes||0)+' · coalesced '+(status.readPaths.prRefreshCoalesced||0))));const triggers=githubCounterLine('Refresh triggers',status.readPaths.prRefreshesByTrigger);if(triggers)body.append(triggers);const coalesced=githubCounterLine('Coalesced',status.readPaths.prRefreshCoalescedByTrigger);if(coalesced)body.append(coalesced);}if(!body.children.length)return null;const details=element('details','usage-details');details.append(element('summary','',text('Diagnostics')),body);return details;}
412
428
  function githubUsageCard(snapshot,status){
413
429
  const card=element('section','usage-provider');
414
430
  card.append(element('h3','',text('GitHub API')));
415
431
  const resources=snapshot?.resources||{};
432
+ const observed=status?.runtimeRateUsage?.resources||{};
416
433
  const bars=element('div','usage-bars');
434
+ bars.append(githubSecondary(status),element('div','usage-label',text('Request-time X-RateLimit headers')));
435
+ let observedCount=0;
436
+ for(const row of [githubObserved('REST',observed.core),githubObserved('GraphQL',observed.graphql),githubObserved('Search',observed.search)])if(row){bars.append(row);observedCount++;}
437
+ if(!observedCount)bars.append(element('p','empty',text('No request-time headers observed')));
417
438
  bars.append(element('div','usage-label',text('Primary quota (/rate_limit)')));
418
- if(resources.core)bars.append(githubBucket('REST',resources.core));
419
- if(resources.graphql)bars.append(githubBucket('GraphQL',resources.graphql));
420
- if(resources.search)bars.append(githubBucket('Search',resources.search));
421
- if(!bars.children.length)bars.append(element('p','empty',text('Quota unavailable')));
422
- const observed=status?.runtimeRateUsage?.resources||{};
423
- for(const row of [githubObserved('REST',observed.core),githubObserved('GraphQL',observed.graphql),githubObserved('Search',observed.search)])if(row)bars.append(row);
424
- if(status?.runtimeRateUsage?.rejection){
439
+ let quotaCount=0;
440
+ for(const pair of [['REST',resources.core],['GraphQL',resources.graphql],['Search',resources.search]])if(pair[1]){bars.append(githubBucket(pair[0],pair[1]));quotaCount++;}
441
+ if(!quotaCount)bars.append(element('p','empty',text('Quota unavailable')));
442
+ if(status?.runtimeRateUsage?.rejection&&status.runtimeRateUsage.rejection.kind!=='secondary'){
425
443
  const rejection=status.runtimeRateUsage.rejection;
426
- const cooldown=rejection.cooldownUntil?' · cooldown '+new Date(rejection.cooldownUntil).toLocaleTimeString():'';
427
- bars.append(element('div','error-state',text('Throttled · '+(rejection.kind==='unknown'?'rate limit':rejection.kind)+' limit'+cooldown)));
444
+ bars.append(element('div','error-state',text('Throttled · '+(rejection.kind==='unknown'?'rate limit':rejection.kind)+' limit')));
428
445
  }
429
- if(status?.limited){
446
+ if(status?.limited&&status?.runtimeRateUsage?.rejection?.kind!=='secondary'){
430
447
  const until=status.cooldownUntil?' until '+new Date(status.cooldownUntil).toLocaleTimeString():'';
431
448
  bars.append(element('div','error-state',text('Cooldown active'+until)));
432
449
  }
@@ -14,12 +14,13 @@ function transportResource(requestChannel, headers) {
14
14
  return requestChannel === 'graphql' ? 'graphql' : 'core';
15
15
  }
16
16
  function rejectionKind(headers, reason) {
17
- if (/secondary rate|graphql_rate_limit/i.test(reason))
17
+ const remaining = headerNumber(headers['x-ratelimit-remaining']);
18
+ if (remaining === 0)
19
+ return 'primary';
20
+ if (/secondary rate/i.test(reason) || headerNumber(headers['retry-after']) !== null)
18
21
  return 'secondary';
19
- if (/rate limit/i.test(reason) && headerNumber(headers['x-ratelimit-remaining']) !== 0)
22
+ if (/rate limit/i.test(reason) && remaining !== null && remaining > 0)
20
23
  return 'secondary';
21
- if (/rate limit/i.test(reason))
22
- return 'primary';
23
24
  return 'unknown';
24
25
  }
25
26
  function isThrottled(status, reason) {
@@ -51,6 +52,8 @@ export function runtimeRejection(observation) {
51
52
  const headers = {};
52
53
  if (observation.remaining !== null)
53
54
  headers['x-ratelimit-remaining'] = String(observation.remaining);
55
+ if (observation.retryAfterSeconds !== null)
56
+ headers['retry-after'] = String(observation.retryAfterSeconds);
54
57
  const retryAfterSeconds = observation.retryAfterSeconds;
55
58
  return {
56
59
  resource: observation.resource,
package/dist/relayd.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { once } from 'node:events';
3
- import { mkdir, readFile, writeFile } from 'node:fs/promises';
3
+ import { readFile } from 'node:fs/promises';
4
4
  import { homedir } from 'node:os';
5
5
  import { join } from 'node:path';
6
6
  import { ChatGptAdapter, CodexAdapter } from './adapters.js';
@@ -134,17 +134,18 @@ export async function runDaemon(argv) {
134
134
  projectionWriters.set(key, projected.projection);
135
135
  return projected;
136
136
  };
137
- 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) });
138
137
  if (repositories.length === 0)
139
138
  throw new Error(`No Git repositories with GitHub origins found under ${normalized.workspaceRoot ?? defaultWorkspaceRoot()}`);
140
- const cacheDirectory = join(homedir(), '.agents-relay');
141
- const cacheFile = join(cacheDirectory, 'dashboard-jobs.json');
142
- try {
143
- const parsed = JSON.parse(await readFile(cacheFile, 'utf8'));
144
- if (Array.isArray(parsed))
145
- cachedOverviews = parsed;
146
- }
147
- catch { /* first run or invalid cache */ }
139
+ const localSnapshots = await discoverLocalRelayStoreSnapshots(join(homedir(), '.agents-relay', 'stores'));
140
+ const knownRepositories = new Set(repositories);
141
+ cachedOverviews = dashboardManagedJobs(localSnapshots.flatMap(snapshot => {
142
+ if (!snapshot.facts)
143
+ return [];
144
+ return snapshot.jobs.filter(job => knownRepositories.has(job.repository)).map(job => ({
145
+ job, githubState: snapshot.facts.state, draft: false, mergedAt: null, closedAt: null, publication: null,
146
+ }));
147
+ }));
148
+ 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) });
148
149
  const refreshAll = async (full = false) => {
149
150
  if (client.rateLimitStatus().limited)
150
151
  return cachedOverviews;
@@ -157,11 +158,6 @@ export async function runDaemon(argv) {
157
158
  const scanned = new Set(scanRepositories);
158
159
  const merged = dashboardManagedJobs([...cachedOverviews.filter(item => !scanned.has(item.job.repository)), ...refreshed]);
159
160
  cachedOverviews = await overlayOperationalOverviews(merged, (repository, prNumber) => runtimeStore(repository, prNumber));
160
- try {
161
- await mkdir(cacheDirectory, { recursive: true });
162
- await writeFile(cacheFile, JSON.stringify(cachedOverviews), 'utf8');
163
- }
164
- catch { /* cache is best effort */ }
165
161
  return cachedOverviews;
166
162
  }
167
163
  catch (error) {
@@ -175,13 +171,6 @@ export async function runDaemon(argv) {
175
171
  cachedOverviews = await overlayOperationalOverviews(cachedOverviews, (repository, prNumber) => runtimeStore(repository, prNumber));
176
172
  return cachedOverviews;
177
173
  };
178
- const persistCache = async () => {
179
- try {
180
- await mkdir(cacheDirectory, { recursive: true });
181
- await writeFile(cacheFile, JSON.stringify(cachedOverviews), 'utf8');
182
- }
183
- catch { /* cache is best effort */ }
184
- };
185
174
  const pullRequestRefreshFlights = new Map();
186
175
  const pullRequestRefreshCache = new Map();
187
176
  const incrementTrigger = (counts, trigger) => { counts[trigger] = (counts[trigger] ?? 0) + 1; };
@@ -209,7 +198,6 @@ export async function runDaemon(argv) {
209
198
  cachedOverviews = cachedOverviews.filter(item => item.job.repository !== repository || item.job.prNumber !== prNumber);
210
199
  cachedOverviews.push(...operational);
211
200
  cachedOverviews = dashboardManagedJobs(cachedOverviews);
212
- await persistCache();
213
201
  const operationalSnapshot = { ...snapshot, overviews: operational };
214
202
  pullRequestRefreshCache.set(key, { snapshot: operationalSnapshot, completedAt: Date.now() });
215
203
  return operationalSnapshot;
@@ -384,21 +372,6 @@ export async function runDaemon(argv) {
384
372
  store.close(); server.close(() => process.exit(0)); }); };
385
373
  process.once('SIGINT', stop);
386
374
  process.once('SIGTERM', stop);
387
- const localSnapshots = await discoverLocalRelayStoreSnapshots(join(homedir(), '.agents-relay', 'stores'));
388
- const knownRepositories = new Set(repositories);
389
- const localOverviews = localSnapshots.flatMap(snapshot => {
390
- if (!snapshot.facts)
391
- return [];
392
- return snapshot.jobs.filter(job => knownRepositories.has(job.repository)).map(job => ({
393
- job, githubState: snapshot.facts.state, draft: false, mergedAt: null, closedAt: null, publication: null,
394
- }));
395
- });
396
- const byJob = new Map();
397
- for (const item of cachedOverviews)
398
- byJob.set(item.job.repository + '#' + item.job.prNumber + ':' + item.job.id, item);
399
- for (const item of localOverviews)
400
- byJob.set(item.job.repository + '#' + item.job.prNumber + ':' + item.job.id, item);
401
- cachedOverviews = dashboardManagedJobs([...byJob.values()]);
402
375
  await pool.reconcileKnown(cachedOverviews);
403
376
  await scheduleHeartbeatSafely();
404
377
  await flushProjectionsSafely();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-relay",
3
- "version": "2.0.3",
3
+ "version": "2.0.5",
4
4
  "description": "Durable async agent jobs coordinated through GitHub pull requests",
5
5
  "type": "module",
6
6
  "bin": {