agents-relay 1.0.101 → 1.0.103

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.
@@ -260,7 +260,8 @@ async function refresh(force=false){jobsRefresh.disabled=true;jobsRefresh.textCo
260
260
  function renderEventScope(){const task=eventTaskId&&selected()?.tasks?.find(candidate=>candidate.id===eventTaskId);eventsAllTasks.disabled=!task;eventsAllTasks.setAttribute('aria-pressed',String(!task));eventsScope.textContent=task?'Task '+task.id+' · newest first · 60 max':'Job-wide · newest first · 60 max';}
261
261
  function eventLabel(type){return ({'task.process.launched':'Process launched','task.started':'Worker started','task.process.exited':'Sync process exited','task.process.async_exited':'Async process exited','task.completed':'Worker completed','task.failed':'Worker failed','task.blocked':'Worker blocked','review.started':'Review started','review.approved':'Review approved','review.changes_required':'Review changes required','review.blocked':'Review blocked'})[type]||type||'Event';}
262
262
  function addEvent(event,level){if(!event||selectedJob&&event.job_id&&event.job_id!==selectedJob||eventTaskId&&event.task_id!==eventTaskId)return;if(event.event_id&&displayedEventIds.has(event.event_id))return;if(event.event_id)displayedEventIds.add(event.event_id);const item=element('li','event-item');const meta=element('div','event-meta');meta.append(element('span','event-kind',text(eventLabel(event.type))),element('span','event-type',text(event.type||'event')),element('span','',text(relativeTime(event.timestamp))),element('span','',text('task: '+value(event.task_id))));item.append(meta);const message=element('p','event-message',text(event.message||'Event received'));if(level==='error'||event.level==='error')message.classList.add('error-text');item.append(message);eventFeed.querySelector('.empty')?.remove();eventFeed.prepend(item);while(eventFeed.children.length>MAX_EVENTS)eventFeed.lastElementChild?.remove();}
263
- function usageBar(label,windowData){const percent=Math.max(0,Math.min(100,100-windowData.usedPercent));const row=element('div','usage-row');const heading=element('div','usage-label');heading.append(element('span','',text(label)),element('span','',text(Math.round(percent)+'%')));const track=element('div','usage-track');track.setAttribute('role','progressbar');track.setAttribute('aria-label',label+' available');track.setAttribute('aria-valuemin','0');track.setAttribute('aria-valuemax','100');track.setAttribute('aria-valuenow',String(percent));const fill=element('div','usage-fill');fill.style.width=percent+'%';track.append(fill);row.append(heading,track);return row;}
263
+ function resetText(windowData){if(!Number.isFinite(windowData.resetsAt))return '';const at=new Date(windowData.resetsAt*1000);const minutes=Math.max(0,Math.ceil((at.getTime()-Date.now())/60000));const relative=minutes<60?minutes+'m':minutes<1440?Math.floor(minutes/60)+'h '+minutes%60+'m':Math.floor(minutes/1440)+'d '+Math.floor((minutes%1440)/60)+'h';return 'Resets '+at.toLocaleString()+' · in '+relative;}
264
+ function usageBar(label,windowData){const percent=Math.max(0,Math.min(100,100-windowData.usedPercent));const row=element('div','usage-row');const heading=element('div','usage-label');heading.append(element('span','',text(label)),element('span','',text(Math.round(percent)+'%')));const track=element('div','usage-track');track.setAttribute('role','progressbar');track.setAttribute('aria-label',label+' available');track.setAttribute('aria-valuemin','0');track.setAttribute('aria-valuemax','100');track.setAttribute('aria-valuenow',String(percent));const fill=element('div','usage-fill');fill.style.width=percent+'%';track.append(fill);row.append(heading,track);const reset=resetText(windowData);if(reset)row.append(element('div','subtle',text(reset)));return row;}
264
265
  function usageProvider(provider){const card=element('section','usage-provider');card.append(element('h3','',text(provider.label)));const usage=provider.usage||{};if(!usage.available||!usage.fiveHour||!usage.weekly){card.append(element('p','empty',text(usage.reason||'Account usage telemetry is unavailable.')));return card;}const bars=element('div','usage-bars');bars.append(usageBar('5-hour remaining',usage.fiveHour),usageBar('Weekly remaining',usage.weekly));card.append(bars);return card;}
265
266
  function formatCredit(value,currency){if(value===null||value===undefined||!Number.isFinite(Number(value)))return '—';const number=Number(value);if(currency==='USD')return '$'+number.toFixed(2);if(currency==='CNY')return '¥'+number.toFixed(2);return number.toFixed(2)+' '+currency;}
266
267
  function creditProvider(provider){const row=element('section','usage-provider');row.append(element('h3','',text(provider.label)));const credits=provider.credits||{};if(!credits.available||!Array.isArray(credits.balances)||!credits.balances.length){row.append(element('p','empty',text(credits.reason||'Credit balance is unavailable.')));return row;}const balance=credits.balances[0];row.append(element('div','detail-value',text(formatCredit(balance.remaining,balance.currency))));return row;}
@@ -38,7 +38,7 @@ async function readConfig(path) {
38
38
  }
39
39
  function withResource(candidate, usage) {
40
40
  const resource = usageForProvider(usage, candidate.provider);
41
- return { ...candidate, resourceHealthy: resource.healthy, resourceUsedPercent: resource.usedPercent };
41
+ return { ...candidate, resourceHealthy: resource.healthy, resourceUsedPercent: resource.usedPercent, resourceRemainingPercent: resource.remainingPercent, resourceResetAt: resource.resetAt, resourceResetInMinutes: resource.resetInMinutes, resourceWindows: resource.windows };
42
42
  }
43
43
  export async function discoverModelCandidates(worker, registered, usage, codexHome = process.env.CODEX_HOME ?? join(homedir(), '.codex')) {
44
44
  const registration = registered.find(item => item.id === worker.workerId);
@@ -105,9 +105,9 @@ export async function routeModel(worker, candidates, options = {}) {
105
105
  facts: JSON.parse(JSON.stringify({
106
106
  task: options.taskFacts ?? {},
107
107
  worker: { id: worker.workerId, adapter: worker.adapter, capabilities: [...worker.capabilities] },
108
- candidates: eligible.map(item => ({ id: item.id, provider: item.provider, model: item.model, profile: item.profile ?? null, reasoning: item.reasoning ?? null, resourceHealthy: item.resourceHealthy ?? null, resourceUsedPercent: item.resourceUsedPercent ?? null })),
108
+ candidates: eligible.map(item => ({ id: item.id, provider: item.provider, model: item.model, profile: item.profile ?? null, reasoning: item.reasoning ?? null, resourceHealthy: item.resourceHealthy ?? null, resourceUsedPercent: item.resourceUsedPercent ?? null, resourceRemainingPercent: item.resourceRemainingPercent ?? null, resourceResetAt: item.resourceResetAt ?? null, resourceResetInMinutes: item.resourceResetInMinutes ?? null, resourceWindows: item.resourceWindows ?? null })),
109
109
  })),
110
- }, { type: 'choice', question: 'Which eligible model profile is best suited to execute this task?', choices: eligible.map(item => item.id) }, () => deterministic.id);
110
+ }, { type: 'choice', question: 'Which eligible model profile is best suited to execute this task? Prefer spending otherwise-expiring paid quota on stronger reasoning when reset is near and substantial capacity remains, but consider every quota window so a tighter longer-horizon limit is not needlessly depleted; never violate task fit or continuity.', choices: eligible.map(item => item.id) }, () => deterministic.id);
111
111
  const selected = eligible.find(item => item.id === choice.choice) ?? deterministic;
112
112
  return {
113
113
  candidate: selected,
@@ -86,6 +86,27 @@ export class ReconcilerRuntimeBase {
86
86
  }
87
87
  async idle() { while (this.live.size > 0 || this.plannerLive.size > 0 || this.continuationLive.size > 0 || this.reconciling)
88
88
  await new Promise(resolve => setTimeout(resolve, 25)); }
89
+ async close() {
90
+ for (const live of this.live.values()) {
91
+ live.controller.abort();
92
+ live.execution.cancel();
93
+ }
94
+ this.live.clear();
95
+ for (const timer of this.terminalTimers.values())
96
+ clearTimeout(timer);
97
+ this.terminalTimers.clear();
98
+ for (const timer of this.terminalExitGraceTimers.values())
99
+ clearTimeout(timer);
100
+ this.terminalExitGraceTimers.clear();
101
+ for (const planner of this.plannerLive.values()) {
102
+ clearTimeout(planner.timer);
103
+ planner.controller.abort();
104
+ }
105
+ this.plannerLive.clear();
106
+ for (const waiter of this.terminalWaiters.values())
107
+ waiter.resolve();
108
+ this.terminalWaiters.clear();
109
+ }
89
110
  async wake(jobId) { return this.reconcile(jobId); }
90
111
  async startAck(jobId, taskId, sourceId, launchBarrier, timeoutMs = this.options.startAckTimeoutMs ?? 60000) {
91
112
  if (!this.options.eventBus)
@@ -146,8 +146,13 @@ export class Reconciler extends ReconcilerReviewBase {
146
146
  this.plannerLive.get(task.id)?.controller.abort();
147
147
  }
148
148
  const beforeSchedule = await this.store.load(jobId);
149
+ const taskStatesBeforeSchedule = new Map(beforeSchedule.tasks.map(task => [task.id, task.state]));
149
150
  const scheduleBefore = JSON.stringify(beforeSchedule.schedule ?? null);
150
151
  let scheduled = schedule(beforeSchedule);
152
+ for (const task of scheduled.tasks) {
153
+ if (taskStatesBeforeSchedule.get(task.id) !== task.state)
154
+ await this.store.saveTask(task);
155
+ }
151
156
  if (scheduled.state !== previousState || JSON.stringify(scheduled.schedule ?? null) !== scheduleBefore)
152
157
  await this.store.saveJob(scheduled);
153
158
  if (prState === 'OPEN') {
@@ -1,9 +1,31 @@
1
1
  import { chooseWithDecision } from './decision.js';
2
- export function usageForProvider(snapshot, provider) {
2
+ export function usageForProvider(snapshot, provider, now = Date.now()) {
3
3
  const usage = snapshot?.providers.find(item => item.id === provider)?.usage;
4
4
  if (usage?.available) {
5
5
  const usedPercent = Math.max(usage.fiveHour?.usedPercent ?? 0, usage.weekly?.usedPercent ?? 0);
6
- return { healthy: usedPercent < 100, usedPercent };
6
+ const resourceWindow = (window) => {
7
+ if (!window)
8
+ return undefined;
9
+ const resetAt = window.resetsAt;
10
+ return {
11
+ remainingPercent: 100 - window.usedPercent,
12
+ resetAt,
13
+ resetInMinutes: resetAt === null ? null : Math.max(0, Math.ceil((resetAt * 1000 - now) / 60_000)),
14
+ };
15
+ };
16
+ const windows = { fiveHour: resourceWindow(usage.fiveHour), weekly: resourceWindow(usage.weekly) };
17
+ const resetWindow = [usage.fiveHour, usage.weekly]
18
+ .filter((window) => window?.resetsAt !== null && window?.resetsAt !== undefined)
19
+ .filter(window => window.resetsAt * 1000 > now)
20
+ .sort((left, right) => left.resetsAt - right.resetsAt)[0];
21
+ return {
22
+ healthy: usedPercent < 100,
23
+ usedPercent,
24
+ remainingPercent: resetWindow ? 100 - resetWindow.usedPercent : undefined,
25
+ resetAt: resetWindow?.resetsAt ?? undefined,
26
+ resetInMinutes: resetWindow?.resetsAt ? Math.max(0, Math.ceil((resetWindow.resetsAt * 1000 - now) / 60_000)) : undefined,
27
+ windows,
28
+ };
7
29
  }
8
30
  const credits = snapshot?.credits.find(item => item.id === provider)?.credits;
9
31
  if (credits?.available) {
@@ -34,6 +56,10 @@ export function workerCandidates(adapters, registered, snapshot) {
34
56
  resourceHealthy: resource.healthy,
35
57
  resourceProvider: provider,
36
58
  resourceUsedPercent: resource.usedPercent,
59
+ resourceRemainingPercent: resource.remainingPercent,
60
+ resourceResetAt: resource.resetAt,
61
+ resourceResetInMinutes: resource.resetInMinutes,
62
+ resourceWindows: resource.windows,
37
63
  };
38
64
  });
39
65
  });
@@ -76,6 +102,10 @@ function selection(selected, selectedBy, decisionEvidence) {
76
102
  resourceHealthy: selected.resourceHealthy,
77
103
  resourceProvider: selected.resourceProvider,
78
104
  resourceUsedPercent: selected.resourceUsedPercent,
105
+ resourceRemainingPercent: selected.resourceRemainingPercent,
106
+ resourceResetAt: selected.resourceResetAt,
107
+ resourceResetInMinutes: selected.resourceResetInMinutes,
108
+ resourceWindows: selected.resourceWindows,
79
109
  decisionEvidence,
80
110
  };
81
111
  }
@@ -108,6 +138,10 @@ export async function routeWorker(candidates, requirements, options = {}) {
108
138
  resourceHealthy: item.resourceHealthy ?? null,
109
139
  resourceProvider: item.resourceProvider ?? null,
110
140
  resourceUsedPercent: item.resourceUsedPercent ?? null,
141
+ resourceRemainingPercent: item.resourceRemainingPercent ?? null,
142
+ resourceResetAt: item.resourceResetAt ?? null,
143
+ resourceResetInMinutes: item.resourceResetInMinutes ?? null,
144
+ resourceWindows: item.resourceWindows ?? null,
111
145
  })),
112
146
  }));
113
147
  const choice = await chooseWithDecision(options.decisionEngine, options.decisionMode ?? 'off', { facts }, { type: 'choice', question: 'Which eligible worker is best suited to execute this task?', choices: eligible.map(item => item.workerId) }, () => deterministic.workerId);
@@ -1,18 +1,26 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import { parseExecutableMermaid, WorkflowEngine } from './mermaid.js';
3
- import { WORKFLOW_EFFECT_NAMES, WORKFLOW_EVENT_NAMES, WORKFLOW_MUTATION_NAMES } from './contracts.js';
3
+ import { WORKFLOW_EFFECT_NAMES, WORKFLOW_EVENT_NAMES, WORKFLOW_FACT_NAMES, WORKFLOW_MUTATION_NAMES, classifyWorkflowActions } from './contracts.js';
4
4
  function declarativeActions() {
5
5
  return Object.fromEntries([...WORKFLOW_MUTATION_NAMES, ...WORKFLOW_EFFECT_NAMES].map(name => [name, { declarative: true }]));
6
6
  }
7
- function load(name, guards = {}) {
8
- const url = new URL(`../../workflow/${name}.mmd`, import.meta.url);
9
- const graph = parseExecutableMermaid(readFileSync(url, 'utf8'));
7
+ export function validateCanonicalWorkflowGraph(graph) {
10
8
  const canonicalEvents = new Set(WORKFLOW_EVENT_NAMES);
9
+ const canonicalFacts = new Set(WORKFLOW_FACT_NAMES);
11
10
  for (const transition of graph.transitions) {
12
11
  if (!canonicalEvents.has(transition.event)) {
13
12
  throw new Error(`Unknown canonical workflow event ${transition.event} referenced at line ${transition.line}`);
14
13
  }
14
+ if (transition.guard && !canonicalFacts.has(transition.guard)) {
15
+ throw new Error(`Unknown canonical workflow fact ${transition.guard} referenced at line ${transition.line}`);
16
+ }
17
+ classifyWorkflowActions(transition.actions);
15
18
  }
19
+ }
20
+ function load(name, guards = {}) {
21
+ const url = new URL(`../../workflow/${name}.mmd`, import.meta.url);
22
+ const graph = parseExecutableMermaid(readFileSync(url, 'utf8'));
23
+ validateCanonicalWorkflowGraph(graph);
16
24
  return new WorkflowEngine(graph, guards, declarativeActions());
17
25
  }
18
26
  const booleanGuard = (name) => context => context[name] === true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-relay",
3
- "version": "1.0.101",
3
+ "version": "1.0.103",
4
4
  "description": "Durable async agent jobs coordinated through GitHub pull requests",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,7 +9,7 @@
9
9
  },
10
10
  "scripts": {
11
11
  "build": "tsc -p tsconfig.json && chmod +x dist/cli.js dist/relayd.js",
12
- "test": "npm run build && node --test --test-force-exit test/*.test.js",
12
+ "test": "npm run build && node --test test/*.test.js",
13
13
  "test:chatgpt-temp-quality": "AGENTS_RELAY_RUN_CHATGPT_TEMP_QUALITY=1 AGENTS_RELAY_CHATGPT_BROWSER_WORKER_AGENT=/Users/chengli/Workspace/neo/skills/chatgpt-browser-worker/agents/browser-worker.agent.md sh -c \"npm run build && node test/quality/run-temporary-chat.mjs\"",
14
14
  "dev": "tsx src/cli.ts"
15
15
  },