agents-relay 1.0.125 → 1.0.127

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
@@ -56,12 +56,24 @@ The canonical Mermaid Graph Engine is the single authoritative Job/Task lifecycl
56
56
  Job objectives and Task inputs are Markdown. Prefer `--objective-file` / `--input-file` (use `-` for stdin) for durable long-form context; inline `--objective` / `--input` remain for short/simple text and are mutually exclusive with the file variants. When a diagram materially clarifies the work, put Mermaid first and then Markdown detail. A newly created Job must capture enough context to finish independently of the originating chat. Its PR body contains exactly one hidden `agents-relay:job:v1` metadata marker followed by the objective Markdown; there is no separate Job comment duplicating the objective. Objective and Job metadata updates rewrite that same PR body. Legacy Job-comment markers remain readable and are migrated into the PR body on the next Job save.
57
57
 
58
58
  Jobs default to `fixed`. `job create` persists only the durable job and never
59
- creates executable tasks. The top-level orchestrator decides whether the user's
60
- request should start immediately and, if so, creates the first task batch
61
- separately. Direct orchestrator-created tasks are persisted directly and are not wrapped in a synthetic Plan. A zero-task autonomous job remains idle;
62
- reconciliation does not invent its first planner. After an autonomous job has
63
- been explicitly started, later planning rounds may extend the work. Fixed jobs
64
- never grow another planning round.
59
+ creates executable tasks. The caller can either create work directly with
60
+ `task create` or invoke the Planner explicitly with `plan`. Direct
61
+ orchestrator-created tasks are persisted directly and are not wrapped in a
62
+ synthetic Plan. `agents-relay plan` works immediately on a zero-task DRAFT Job,
63
+ activates it, persists a real `plan-N` planning round, and materializes validated
64
+ `next_tasks`. Autonomous reconciliation still does not invent the first round
65
+ for an untouched zero-task Job; after explicit work begins, later autonomous
66
+ planning rounds reuse the same planning implementation. Fixed jobs never grow
67
+ another planning round automatically.
68
+
69
+ Manual planning syntax:
70
+
71
+ ~~~sh
72
+ npx agents-relay plan --repo OWNER/REPO --pr 12 --id job-1 --prompt "Plan the implementation from the durable objective"
73
+ npx agents-relay plan --repo OWNER/REPO --pr 12 --id job-1 --prompt-file guidance.md
74
+ ~~~
75
+
76
+ The optional prompt is additional round guidance; it does not replace the durable Job objective. New planning rounds use `plan-N`; historical `planner-N` records remain readable.
65
77
 
66
78
  The durable status JSON and dashboard show the mode and planning provenance. An
67
79
  autonomous runtime uses the bundled `skills/agents-relay/agents/planner.agent.md` by default; `--planner-command` is an optional override
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|plan|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
 
@@ -8,12 +8,14 @@ Usage:
8
8
  Commands:
9
9
  job create|update|show|list|merge|cancel Manage durable GitHub-backed jobs
10
10
  task create|update|show|list|complete|retry|cancel Manage durable tasks
11
+ plan Invoke the Planner for an existing managed Job
11
12
  blocker add|resolve|show Manage cross-Job blocker dependencies
12
13
  context resolve Resolve shared project instructions, skills, agents, and runtimes
13
14
  init Create a local demo job or initialize a GitHub job
14
15
  record Backfill a completed task into durable history
15
16
  agent-register Register an agent role and its capabilities
16
17
  agent-discover Find registered agents matching hard filters
18
+ status Show live aggregate runtime status from agents-relayd
17
19
  reconcile Run reconciliation once
18
20
  tick Run the indexed autonomous scheduler heartbeat
19
21
  serve Run the dashboard and reconciliation service
@@ -75,6 +77,12 @@ Required: --repo OWNER/REPO, --pr NUMBER, and --id JOB_ID. The current PR head S
75
77
  'job cancel': `Cancel the durable Job and close its backing pull request.
76
78
 
77
79
  Required: --repo OWNER/REPO, --pr NUMBER, and --id JOB_ID. Nonterminal Tasks are cancelled before the PR is closed.`,
80
+ status: `Show live aggregate runtime status from the running Agents Relay service.
81
+
82
+ Usage:
83
+ npx agents-relay status [--host HOST] [--port PORT]
84
+
85
+ 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
86
  task: `Manage durable tasks.
79
87
 
80
88
  Usage:
@@ -119,6 +127,15 @@ Required: --task-id ID and the normal Job storage options.`,
119
127
  'task cancel': `Cancel one durable Task.
120
128
 
121
129
  Required: --task-id ID and the normal Job storage options.`,
130
+ plan: `Invoke the Planner against the current durable Job state.
131
+
132
+ Required: --repo OWNER/REPO, --pr NUMBER, and --id JOB_ID.
133
+ Optional: --prompt TEXT or --prompt-file PATH|- (mutually exclusive). The prompt is additional guidance for this planning round and never replaces the durable Job objective.
134
+
135
+ The command persists one real plan-N round, materializes validated next_tasks with planner provenance, and wakes normal reconciliation. It works for zero-task DRAFT Jobs. Historical planner-N rounds remain readable, but new rounds use plan-N.
136
+
137
+ Example:
138
+ npx agents-relay plan --repo OWNER/REPO --pr 12 --id job-1 --prompt "Plan the full implementation now; keep independent work parallel."`,
122
139
  blocker: `Manage durable cross-Job blocker dependencies.
123
140
 
124
141
  Usage:
package/dist/cli.js CHANGED
@@ -495,7 +495,7 @@ export async function createService(args) {
495
495
  void bus.publish(wakeEvent);
496
496
  else
497
497
  void reconciler.reconcile(loaded.job.id); } } : undefined;
498
- const dashboardServer = serveDashboard(loaded.store, Number(arg(args, '--port', String(SERVICE_DEFAULTS.port))), bus, Number(arg(args, '--refresh-ms', String(SERVICE_DEFAULTS.dashboardRefreshMs))), loaded.job.id, usageRegistry, overviewProvider, webhook, undefined, undefined, {
498
+ const dashboardServer = serveDashboard(loaded.store, Number(arg(args, '--port', String(SERVICE_DEFAULTS.port))), bus, Number(arg(args, '--refresh-ms', String(SERVICE_DEFAULTS.dashboardRefreshMs))), loaded.job.id, usageRegistry, overviewProvider, webhook, undefined, undefined, undefined, {
499
499
  start: dashboardJobAction(loaded.store, reconciler, 'start'),
500
500
  reconcile: dashboardJobAction(loaded.store, reconciler, 'reconcile'),
501
501
  cancel: dashboardJobAction(loaded.store, reconciler, 'cancel'),
@@ -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);
@@ -620,6 +637,19 @@ async function main() {
620
637
  }
621
638
  const loaded = await storeFor(args);
622
639
  const { store, job, localFile } = loaded;
640
+ if (command === 'plan') {
641
+ const guidance = hasArg(args, '--prompt') || hasArg(args, '--prompt-file') ? await textOption(args, '--prompt', '--prompt-file') : undefined;
642
+ try {
643
+ const result = await runtime(store, args, bus).planJob(job.id, guidance?.trim() || undefined);
644
+ await saveLocal(localFile, result.job);
645
+ await publishWake(bus, result.job, result.round.id, `Planning round ${result.round.id} completed`);
646
+ console.log(JSON.stringify(result.round, null, 2));
647
+ }
648
+ finally {
649
+ await bus?.close?.();
650
+ }
651
+ return;
652
+ }
623
653
  if (command === 'agent-register') {
624
654
  const now = new Date().toISOString();
625
655
  const agent = { id: arg(args, '--agent-id', randomUUID()), name: arg(args, '--name'), responsibility: arg(args, '--responsibility'), role: arg(args, '--role'), capabilities: arg(args, '--capabilities').split(',').filter(Boolean), boundaries: arg(args, '--boundaries').split('|').filter(Boolean), endpoint: { kind: arg(args, '--endpoint-kind', 'adapter'), target: arg(args, '--endpoint') }, runtime: { adapter: arg(args, '--runtime', arg(args, '--adapter', 'shell')), provider: arg(args, '--provider') || undefined, model: arg(args, '--model') || undefined }, availability: arg(args, '--availability', 'available'), routing: { labels: arg(args, '--labels').split(',').filter(Boolean).reduce((out, item) => { const [key, value] = item.split('=', 2); if (key && value)
@@ -649,10 +679,6 @@ async function main() {
649
679
  console.log(`Recorded ${id}`);
650
680
  return;
651
681
  }
652
- if (command === 'status') {
653
- console.log(JSON.stringify(await store.load(job.id), null, 2));
654
- return;
655
- }
656
682
  if (command === 'reconcile' && !localFile) {
657
683
  try {
658
684
  await requestManagedReconciliation(bus, job);
@@ -367,7 +367,9 @@ function usageBar(label,windowData){const percent=Math.max(0,Math.min(100,100-wi
367
367
  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;}
368
368
  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;}
369
369
  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;}
370
- async function drawUsage(){try{const overview=await loadJSON('/api/usage');usageRoot.replaceChildren();usageMeta.textContent='Real account telemetry when available';const providers=Array.isArray(overview.providers)?overview.providers:[];const credits=Array.isArray(overview.credits)?overview.credits:[];if(!providers.length&&!credits.length){usageRoot.append(element('p','empty',text('Usage telemetry is unavailable.')));return;}const grid=element('div','usage-providers');for(const provider of providers)grid.append(usageProvider(provider));for(const provider of credits)grid.append(creditProvider(provider));usageRoot.append(grid);}catch(error){usageMeta.textContent='Unavailable';usageRoot.replaceChildren(element('p','error-state',text('Usage unavailable: '+error.message)));}}
370
+ function githubBucket(label,bucket){const row=element('div','usage-row');const heading=element('div','usage-label');heading.append(element('span','',text(label)),element('span','',text(bucket.remaining+' remaining')));row.append(heading);row.append(element('div','subtle',text(bucket.used+' used / '+bucket.limit+' limit')));const reset=resetText({resetsAt:bucket.reset});if(reset)row.append(element('div','subtle',text(reset)));return row;}
371
+ function githubUsageCard(snapshot,status){const card=element('section','usage-provider');card.append(element('h3','',text('GitHub API')));const resources=snapshot?.resources||{};const bars=element('div','usage-bars');if(resources.core)bars.append(githubBucket('REST core',resources.core));if(resources.graphql)bars.append(githubBucket('GraphQL',resources.graphql));if(resources.search)bars.append(githubBucket('Search',resources.search));if(!bars.children.length)bars.append(element('p','empty',text('GitHub quota telemetry is unavailable.')));if(status?.limited)bars.append(element('div','error-state',text('Relay cooldown active'+(status.cooldownUntil?' until '+new Date(status.cooldownUntil).toLocaleString():'')));else bars.append(element('div','subtle',text('Relay cooldown inactive')));if(status?.calls)bars.append(element('div','subtle',text('Calls since start · GraphQL '+(status.calls.graphql||0)+' · REST '+(status.calls.rest||0)+' · quota '+(status.calls.rateLimit||0))));if(status?.readPaths)bars.append(element('div','subtle',text('Read paths · workspace scans '+(status.readPaths.workspaceScans||0)+' · PR refreshes '+(status.readPaths.prRefreshes||0))));card.append(bars);return card;}
372
+ async function drawUsage(){try{const overview=await loadJSON('/api/usage');usageRoot.replaceChildren();usageMeta.textContent='Real account telemetry when available';const providers=Array.isArray(overview.providers)?overview.providers:[];const credits=Array.isArray(overview.credits)?overview.credits:[];const grid=element('div','usage-providers');if(overview.github)grid.append(githubUsageCard(overview.github,overview.githubStatus));for(const provider of providers)grid.append(usageProvider(provider));for(const provider of credits)grid.append(creditProvider(provider));if(!grid.children.length){usageRoot.append(element('p','empty',text('Usage telemetry is unavailable.')));return;}usageRoot.append(grid);}catch(error){usageMeta.textContent='Unavailable';usageRoot.replaceChildren(element('p','error-state',text('Usage unavailable: '+error.message)));}}
371
373
  async function drawRules(){rulesStatus.textContent='';try{const rules=await loadJSON('/api/model-router-rules');rulesText.value=typeof rules.text==='string'?rules.text:'';rulesMeta.textContent=rules.hash&&rules.hash!=='none'?'Loaded · '+rules.hash.slice(0,12):'No custom rules saved';}catch(error){rulesMeta.textContent='Unavailable';rulesStatus.textContent='Could not load rules: '+error.message;}}
372
374
  async function saveRules(){rulesSave.disabled=true;rulesStatus.textContent='Saving…';try{const response=await fetch('/api/model-router-rules',{method:'PUT',headers:{'content-type':'text/plain; charset=utf-8'},body:rulesText.value});if(!response.ok)throw new Error(response.status+' '+await response.text());const rules=await response.json();rulesMeta.textContent='Saved · '+rules.hash.slice(0,12);rulesStatus.textContent='Saved. New routing decisions will load these rules.';}catch(error){rulesStatus.textContent='Save failed: '+error.message;}finally{rulesSave.disabled=false;}}
373
375
  async function drawWorkerRules(){workerRulesStatus.textContent='';try{const rules=await loadJSON('/api/worker-router-rules');workerRulesText.value=typeof rules.text==='string'?rules.text:'';workerRulesMeta.textContent=rules.hash&&rules.hash!=='none'?'Loaded · '+rules.hash.slice(0,12):'No custom rules saved';}catch(error){workerRulesMeta.textContent='Unavailable';workerRulesStatus.textContent='Could not load rules: '+error.message;}}
package/dist/dashboard.js CHANGED
@@ -55,7 +55,12 @@ async function eventHistory(jobId, taskId, limit) {
55
55
  }
56
56
  export { dashboardHtml } from './dashboard-html.js';
57
57
  import { dashboardHtml } from './dashboard-html.js';
58
- export function serveDashboard(store, port = 8787, bus, refreshMs = 300000, jobId, usageRegistry, overviewProvider, webhook, statusProvider, exactJobProvider, actions) {
58
+ export function serveDashboard(store, port = 8787, bus, refreshMs = 300000, jobId, usageRegistry, overviewProvider, webhook, statusProvider, githubUsageProvider, exactJobProvider, actions) {
59
+ const legacyActions = typeof exactJobProvider === 'object' && exactJobProvider !== null ? exactJobProvider : undefined;
60
+ const legacyExactJobProvider = !exactJobProvider && githubUsageProvider ? githubUsageProvider : undefined;
61
+ const resolvedExactJobProvider = typeof exactJobProvider === 'function' ? exactJobProvider : legacyExactJobProvider;
62
+ const resolvedGithubUsageProvider = legacyExactJobProvider ? undefined : githubUsageProvider;
63
+ const resolvedActions = actions ?? legacyActions;
59
64
  const webhookDeliveries = new DeliveryDeduper();
60
65
  const clients = new Set();
61
66
  const subscribedJobs = new Set();
@@ -106,8 +111,8 @@ export function serveDashboard(store, port = 8787, bus, refreshMs = 300000, jobI
106
111
  const snapshot = async (requestedJobId = jobId, forceRefresh = false) => {
107
112
  await subscribeDiscovered();
108
113
  if (overviewProvider) {
109
- if (forceRefresh && requestedJobId && exactJobProvider) {
110
- const exact = await exactJobProvider(requestedJobId);
114
+ if (forceRefresh && requestedJobId && resolvedExactJobProvider) {
115
+ const exact = await resolvedExactJobProvider(requestedJobId);
111
116
  if (exact)
112
117
  return { id: exact.id, json: JSON.stringify(exact) };
113
118
  }
@@ -132,7 +137,16 @@ export function serveDashboard(store, port = 8787, bus, refreshMs = 300000, jobI
132
137
  const usage = async () => {
133
138
  if (!usageRegistry)
134
139
  throw new Error('Account usage telemetry is unavailable');
135
- return JSON.stringify(await readUsageSnapshot(usageRegistry));
140
+ const account = await readUsageSnapshot(usageRegistry);
141
+ const githubStatus = statusProvider ? statusProvider() : { limited: false, cooldownUntil: null };
142
+ let github = null;
143
+ if (resolvedGithubUsageProvider) {
144
+ try {
145
+ github = await resolvedGithubUsageProvider();
146
+ }
147
+ catch { /* quota telemetry is best-effort during GitHub degradation */ }
148
+ }
149
+ return JSON.stringify({ ...account, github, githubStatus });
136
150
  };
137
151
  const server = createServer(async (req, res) => {
138
152
  if (req.url === '/') {
@@ -200,19 +214,24 @@ export function serveDashboard(store, port = 8787, bus, refreshMs = 300000, jobI
200
214
  const url = new URL(req.url ?? '/', 'http://127.0.0.1');
201
215
  const requestedJobId = url.searchParams.get('id') ?? '';
202
216
  const action = url.searchParams.get('action') ?? '';
203
- const handler = action === 'start' ? actions?.start : action === 'reconcile' ? actions?.reconcile : action === 'cancel' ? actions?.cancel : undefined;
217
+ const handler = action === 'start' ? resolvedActions?.start : action === 'reconcile' ? resolvedActions?.reconcile : action === 'cancel' ? resolvedActions?.cancel : undefined;
204
218
  if (req.method !== 'POST' || !/^[A-Za-z0-9_-]+$/.test(requestedJobId) || !handler) {
205
219
  res.writeHead(404);
206
220
  res.end('Job action unavailable');
207
221
  return;
208
222
  }
209
223
  try {
210
- res.writeHead(200, { 'content-type': 'application/json' });
211
- res.end(JSON.stringify(await handler(requestedJobId)));
224
+ const result = await handler(requestedJobId);
225
+ if (!res.headersSent)
226
+ res.writeHead(200, { 'content-type': 'application/json' });
227
+ if (!res.writableEnded)
228
+ res.end(JSON.stringify(result));
212
229
  }
213
230
  catch (error) {
214
- res.writeHead(409);
215
- res.end(error instanceof Error ? error.message : String(error));
231
+ if (!res.headersSent)
232
+ res.writeHead(409);
233
+ if (!res.writableEnded)
234
+ res.end(error instanceof Error ? error.message : String(error));
216
235
  }
217
236
  return;
218
237
  }
@@ -221,19 +240,24 @@ export function serveDashboard(store, port = 8787, bus, refreshMs = 300000, jobI
221
240
  const requestedJobId = url.searchParams.get('id') ?? '';
222
241
  const requestedTaskId = url.searchParams.get('task') ?? '';
223
242
  const action = url.searchParams.get('action') ?? '';
224
- const handler = action === 'retry' ? actions?.taskRetry : action === 'cancel' ? actions?.taskCancel : undefined;
243
+ const handler = action === 'retry' ? resolvedActions?.taskRetry : action === 'cancel' ? resolvedActions?.taskCancel : undefined;
225
244
  if (req.method !== 'POST' || !/^[A-Za-z0-9_-]+$/.test(requestedJobId) || !/^[A-Za-z0-9_-]+$/.test(requestedTaskId) || !handler) {
226
245
  res.writeHead(404);
227
246
  res.end('Task action unavailable');
228
247
  return;
229
248
  }
230
249
  try {
231
- res.writeHead(200, { 'content-type': 'application/json' });
232
- res.end(JSON.stringify(await handler(requestedJobId, requestedTaskId)));
250
+ const result = await handler(requestedJobId, requestedTaskId);
251
+ if (!res.headersSent)
252
+ res.writeHead(200, { 'content-type': 'application/json' });
253
+ if (!res.writableEnded)
254
+ res.end(JSON.stringify(result));
233
255
  }
234
256
  catch (error) {
235
- res.writeHead(409);
236
- res.end(error instanceof Error ? error.message : String(error));
257
+ if (!res.headersSent)
258
+ res.writeHead(409);
259
+ if (!res.writableEnded)
260
+ res.end(error instanceof Error ? error.message : String(error));
237
261
  }
238
262
  return;
239
263
  }
@@ -352,8 +376,15 @@ export function serveDashboard(store, port = 8787, bus, refreshMs = 300000, jobI
352
376
  return;
353
377
  }
354
378
  if (req.url === '/api/runtime-summary') {
355
- res.writeHead(200, { 'content-type': 'application/json' });
356
- res.end(JSON.stringify(summarizeRuntime(lastKnownJobs ?? [])));
379
+ try {
380
+ const jobs = await discoveredJobs();
381
+ res.writeHead(200, { 'content-type': 'application/json' });
382
+ res.end(JSON.stringify(summarizeRuntime(jobs)));
383
+ }
384
+ catch (error) {
385
+ res.writeHead(502);
386
+ res.end(error instanceof Error ? error.message : String(error));
387
+ }
357
388
  return;
358
389
  }
359
390
  if (req.url === '/api/status') {
@@ -0,0 +1,34 @@
1
+ import { createPlannerRoundTask } from './planner.js';
2
+ import { transitionJobEvent } from './scheduler.js';
3
+ export async function executeManualPlan(store, jobId, guidance, timeoutMs, launchPlanner) {
4
+ let job = await store.load(jobId);
5
+ if (['COMPLETED', 'CANCELLED'].includes(job.state))
6
+ throw new Error(`Cannot plan terminal job ${job.id} (${job.state})`);
7
+ const active = job.tasks.find(task => task.kind === 'planner' && ['DRAFT', 'QUEUED', 'RUNNING'].includes(task.state));
8
+ if (active)
9
+ throw new Error(`Planning round ${active.id} is already ${active.state}`);
10
+ const round = createPlannerRoundTask(job, { guidance, manual: true, timeoutMs });
11
+ await store.appendTask(round);
12
+ job = await store.load(jobId);
13
+ if (job.state === 'DRAFT') {
14
+ await transitionJobEvent(job, 'activate');
15
+ await store.saveJob(job);
16
+ job = await store.load(jobId);
17
+ }
18
+ const durableRound = job.tasks.find(task => task.id === round.id);
19
+ if (!durableRound)
20
+ throw new Error(`Planning round ${round.id} was not persisted`);
21
+ await launchPlanner(job, durableRound);
22
+ let completed = await store.load(jobId);
23
+ let completedRound = completed.tasks.find(task => task.id === round.id);
24
+ while (completedRound && ['QUEUED', 'RUNNING'].includes(completedRound.state)) {
25
+ await new Promise(resolve => setTimeout(resolve, 25));
26
+ completed = await store.load(jobId);
27
+ completedRound = completed.tasks.find(task => task.id === round.id);
28
+ }
29
+ if (!completedRound)
30
+ throw new Error(`Planning round ${round.id} disappeared`);
31
+ if (completedRound.state !== 'SUCCEEDED')
32
+ throw new Error(completedRound.error ?? `Planning round ${round.id} ended ${completedRound.state}`);
33
+ return { job: completed, round: completedRound };
34
+ }
package/dist/planner.js CHANGED
@@ -2,8 +2,45 @@ import { spawn } from 'node:child_process';
2
2
  import { readFile } from 'node:fs/promises';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { dirname, join } from 'node:path';
5
- import { taskOverallTimeoutMs } from './types.js';
5
+ import { storySizeTimeoutMs, taskOverallTimeoutMs } from './types.js';
6
6
  import { executionContextPrompt } from './execution-context.js';
7
+ export function plannerRoundNumber(task) {
8
+ if (task.kind !== 'planner')
9
+ return null;
10
+ if (Number.isInteger(task.planningRound) && (task.planningRound ?? 0) > 0)
11
+ return task.planningRound;
12
+ const match = /^(?:plan|planner)-(\d+)$/.exec(task.id);
13
+ return match ? Number(match[1]) : null;
14
+ }
15
+ export function createPlannerRoundTask(job, options = {}) {
16
+ const planners = job.tasks.filter(task => task.kind === 'planner')
17
+ .sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
18
+ const used = new Set(job.tasks.map(task => task.id));
19
+ let round = Math.max(0, ...planners.map(task => plannerRoundNumber(task) ?? 0)) + 1;
20
+ let id = `plan-${round}`;
21
+ while (used.has(id)) {
22
+ round += 1;
23
+ id = `plan-${round}`;
24
+ }
25
+ const previousPlanner = planners.at(-1);
26
+ const now = options.now ?? new Date().toISOString();
27
+ const context = {
28
+ job,
29
+ tasks: job.tasks,
30
+ objective: job.objective ?? job.description ?? job.title,
31
+ previousPlannerTaskId: previousPlanner?.id ?? null,
32
+ guidance: options.guidance,
33
+ };
34
+ return {
35
+ jobId: job.id, id, kind: 'planner', storySize: 'M', agentName: 'planner',
36
+ parentTaskId: previousPlanner?.id ?? null, dependencies: [], capabilities: ['planner'], adapter: 'orchestrator',
37
+ input: plannerInput(context), continuation: null, continuationDeliveredAt: null, state: 'QUEUED', attempt: 0,
38
+ maxAttempts: options.manual ? 1 : 3, leaseOwner: null, leaseExpiresAt: null, executionId: null, threadId: null,
39
+ result: null, plannerResult: null, error: null, timeoutMs: options.timeoutMs ?? storySizeTimeoutMs('M'),
40
+ createdAt: now, updatedAt: now, planningDecisionId: id, planningSource: 'model-planner', planningRound: round,
41
+ plannerGuidance: options.guidance, manualPlanning: options.manual === true,
42
+ };
43
+ }
7
44
  export function planningSpec(task) {
8
45
  if (!task.output && task.adapter !== 'orchestrator')
9
46
  throw new Error(`Initial task ${task.id} requires an output contract`);
@@ -156,6 +193,7 @@ export function plannerInput(context) {
156
193
  return JSON.stringify({
157
194
  objective: context.objective,
158
195
  retry_feedback: context.retryFeedback,
196
+ caller_prompt: context.guidance,
159
197
  previous_planner_task_id: context.previousPlannerTaskId,
160
198
  project: project ? { name: project.name, path: project.path } : undefined,
161
199
  visible_agents: visibleAgents ?? [],
package/dist/pool.js CHANGED
@@ -22,15 +22,18 @@ export class RepositoryWorkerPool {
22
22
  trustedAuthors;
23
23
  concurrency;
24
24
  makeReconciler;
25
+ isRateLimited;
25
26
  running = false;
26
27
  pending = false;
27
28
  reconcileLocks = new Map();
29
+ targetedReconcileFlights = new Map();
28
30
  jobTargets = new Map();
29
- constructor(client, repository, trustedAuthors, concurrency, makeReconciler) {
31
+ constructor(client, repository, trustedAuthors, concurrency, makeReconciler, isRateLimited = () => false) {
30
32
  this.client = client;
31
33
  this.trustedAuthors = trustedAuthors;
32
34
  this.concurrency = concurrency;
33
35
  this.makeReconciler = makeReconciler;
36
+ this.isRateLimited = isRateLimited;
34
37
  this.repositories = [...new Set(typeof repository === 'string' ? [repository] : repository)];
35
38
  }
36
39
  repositories;
@@ -79,6 +82,8 @@ export class RepositoryWorkerPool {
79
82
  }
80
83
  async reconcileKnown(discovered) { await this.reconcileDiscovered(discovered); }
81
84
  async reconcile() {
85
+ if (this.isRateLimited())
86
+ return;
82
87
  if (this.running) {
83
88
  this.pending = true;
84
89
  return;
@@ -95,15 +100,29 @@ export class RepositoryWorkerPool {
95
100
  }
96
101
  }
97
102
  async reconcileTarget(repository, prNumber, jobId) {
98
- if (!this.repositories.includes(repository))
103
+ if (this.isRateLimited() || !this.repositories.includes(repository))
99
104
  return;
100
105
  this.jobTargets.set(jobId, { repository, prNumber });
101
- await this.reconcileExclusive(jobId, async () => {
106
+ const key = `${repository}#${prNumber}:${jobId}`;
107
+ const existing = this.targetedReconcileFlights.get(key);
108
+ if (existing)
109
+ return existing;
110
+ const flight = this.reconcileExclusive(jobId, async () => {
102
111
  const store = new GitHubStore(this.client, repository, prNumber, this.trustedAuthors);
103
112
  await this.makeReconciler(store, Math.max(1, this.concurrency)).reconcile(jobId);
104
113
  });
114
+ this.targetedReconcileFlights.set(key, flight);
115
+ try {
116
+ await flight;
117
+ }
118
+ finally {
119
+ if (this.targetedReconcileFlights.get(key) === flight)
120
+ this.targetedReconcileFlights.delete(key);
121
+ }
105
122
  }
106
123
  async handleLifecycleEvent(event) {
124
+ if (this.isRateLimited())
125
+ return;
107
126
  const target = this.jobTargets.get(event.job_id);
108
127
  if (!target || !this.repositories.includes(target.repository)) {
109
128
  process.stderr.write(`warning: terminal event target unknown for job ${event.job_id}\n`);
@@ -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);
@@ -4,10 +4,11 @@ import { dependencyReady, schedule, transitionJobEvent, transitionTaskEvent } fr
4
4
  import { implementationComplete } from './runtime-status.js';
5
5
  import { LaunchFailure, StartAckTimeout } from './adapters.js';
6
6
  import { eventFor } from './events.js';
7
- import { parsePlannerResult, plannerInput } from './planner.js';
7
+ import { createPlannerRoundTask, parsePlannerResult, plannerInput } from './planner.js';
8
8
  import { agentContractPrompt, executionContextPrompt, resolveExecutionContext } from './execution-context.js';
9
9
  import { agentGraphPrompt, validateAgentSelection } from './agent-graph.js';
10
10
  import { ReconcilerReviewBase } from './reconciler-review.js';
11
+ import { executeManualPlan } from './manual-plan.js';
11
12
  import { executionEventSource } from './reconciler-runtime.js';
12
13
  export { executionEventSource } from './reconciler-runtime.js';
13
14
  import { CallbackWorkflowEffectHandler } from './workflow/effects.js';
@@ -248,6 +249,9 @@ export class Reconciler extends ReconcilerReviewBase {
248
249
  }
249
250
  }
250
251
  }
252
+ async planJob(jobId, guidance) {
253
+ return executeManualPlan(this.store, jobId, guidance, this.options.plannerTimeoutMs ?? storySizeTimeoutMs('M'), (job, task) => this.launchPlanner(job, task));
254
+ }
251
255
  async maybeCreatePlanner(job) {
252
256
  if (job.executionMode !== 'autonomous')
253
257
  return job;
@@ -274,16 +278,7 @@ export class Reconciler extends ReconcilerReviewBase {
274
278
  return job;
275
279
  if (latest && !job.schedule && job.review && job.review.outcome !== 'approved')
276
280
  return job;
277
- const previousPlanner = allPlanners.at(-1);
278
- const used = new Set(job.tasks.map(task => task.id));
279
- let number = allPlanners.length + 1;
280
- let id = `planner-${number}`;
281
- while (used.has(id)) {
282
- number += 1;
283
- id = `planner-${number}`;
284
- }
285
- const now = new Date().toISOString();
286
- const task = { jobId: job.id, id, kind: 'planner', storySize: 'M', agentName: 'planner', parentTaskId: previousPlanner?.id ?? null, dependencies: [], capabilities: ['planner'], adapter: 'orchestrator', input: plannerInput({ job, tasks: job.tasks, objective: job.objective ?? job.description ?? job.title, previousPlannerTaskId: previousPlanner?.id ?? null }), continuation: null, continuationDeliveredAt: null, state: 'QUEUED', attempt: 0, maxAttempts: 3, leaseOwner: null, leaseExpiresAt: null, executionId: null, threadId: null, result: null, plannerResult: null, error: null, timeoutMs: this.options.plannerTimeoutMs ?? storySizeTimeoutMs('M'), createdAt: now, updatedAt: now, planningDecisionId: id, planningSource: 'model-planner', planningRound: allPlanners.length + 1 };
281
+ const task = createPlannerRoundTask(job, { timeoutMs: this.options.plannerTimeoutMs ?? storySizeTimeoutMs('M') });
287
282
  await this.store.appendTask(task);
288
283
  await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'planner.created', 'queued', `Planner task ${task.id} created`, 'orchestrator'));
289
284
  return this.store.load(job.id);
@@ -456,7 +451,7 @@ export class Reconciler extends ReconcilerReviewBase {
456
451
  let promise;
457
452
  try {
458
453
  const executionContext = await this.plannerContext(job, task);
459
- const context = { job, tasks: job.tasks, objective: job.objective ?? job.description ?? job.title, previousPlannerTaskId: task.parentTaskId, executionContext, retryFeedback: task.plannerRetryFeedback };
454
+ const context = { job, tasks: job.tasks, objective: job.objective ?? job.description ?? job.title, previousPlannerTaskId: task.parentTaskId, executionContext, retryFeedback: task.plannerRetryFeedback, guidance: task.plannerGuidance };
460
455
  task.input = plannerInput(context);
461
456
  await this.store.saveTask(task);
462
457
  promise = planner.plan(context, controller.signal);
@@ -499,8 +494,8 @@ export class Reconciler extends ReconcilerReviewBase {
499
494
  const children = result.next_tasks.map(spec => this.plannerChild(job, task, spec, executionContext));
500
495
  for (const child of children) {
501
496
  const existing = job.tasks.find(item => item.id === child.id);
502
- if (!existing)
503
- continue;
497
+ if (existing && !samePlannerChildDefinition(existing, child))
498
+ throw new Error(`Planner child ${child.id} already exists with a different definition`);
504
499
  }
505
500
  // Persist the completed decision before exposing executable children.
506
501
  task.plannerResult = result;
@@ -537,7 +532,8 @@ export class Reconciler extends ReconcilerReviewBase {
537
532
  await this.emit(eventFor(job.id, task.id, task.parentTaskId, task.state === 'FAILED' ? 'planner.failed' : 'planner.retry', task.state === 'FAILED' ? 'failed' : 'queued', `Planner ${task.id}: ${message}`, 'user'));
538
533
  }
539
534
  }
540
- this.detach(this.reconcile(jobId), jobId, task.id, 'planner follow-up reconciliation');
535
+ if (!task.manualPlanning)
536
+ this.detach(this.reconcile(jobId), jobId, task.id, 'planner follow-up reconciliation');
541
537
  }
542
538
  plannerChild(job, planner, spec, executionContext) {
543
539
  validateAgentSelection(executionContext, spec.agentName, spec.agentGraph);
package/dist/relayd.js CHANGED
@@ -53,6 +53,26 @@ export function normalizeDaemonArguments(argv) {
53
53
  throw new Error('--pr must be positive');
54
54
  return { repository, workspaceRoot: repository ? null : workspaceRoot, pullRequest, jobId: id || null, serviceArguments: [...argv] };
55
55
  }
56
+ export function recoveryScanRepositories(repositories, cached, dormantProbeIndex = 0) {
57
+ const byRepository = new Map();
58
+ for (const item of cached) {
59
+ const values = byRepository.get(item.job.repository) ?? [];
60
+ values.push(item);
61
+ byRepository.set(item.job.repository, values);
62
+ }
63
+ const activeOrUnknown = [];
64
+ const dormant = [];
65
+ for (const repository of repositories) {
66
+ const values = byRepository.get(repository);
67
+ if (!values || values.length === 0 || values.some(item => item.githubState === 'OPEN' && !['COMPLETED', 'CANCELLED'].includes(item.job.state)))
68
+ activeOrUnknown.push(repository);
69
+ else
70
+ dormant.push(repository);
71
+ }
72
+ if (dormant.length === 0)
73
+ return activeOrUnknown;
74
+ return [...activeOrUnknown, dormant[Math.abs(dormantProbeIndex) % dormant.length]];
75
+ }
56
76
  export async function runDaemon(argv) {
57
77
  if (argv.includes('--help') || argv.includes('-h')) {
58
78
  console.log(daemonHelp);
@@ -76,10 +96,12 @@ export async function runDaemon(argv) {
76
96
  const usageRegistry = codexAndZaiUsageRegistry();
77
97
  const decisionAuditStore = new JsonlDecisionAuditStore(process.env.AGENTS_RELAY_DECISION_AUDIT_PATH ?? join(homedir(), '.agents-relay', 'decision-audit.jsonl'));
78
98
  let cachedOverviews = [];
99
+ const githubReadPaths = { workspaceScans: 0, prRefreshes: 0 };
100
+ let dormantProbeIndex = 0;
79
101
  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) });
80
102
  if (repositories.length === 0)
81
103
  throw new Error(`No Git repositories with GitHub origins found under ${normalized.workspaceRoot ?? defaultWorkspaceRoot()}`);
82
- const pool = new RepositoryWorkerPool(client, repositories, trusted, concurrency, makeReconciler);
104
+ const pool = new RepositoryWorkerPool(client, repositories, trusted, concurrency, makeReconciler, () => client.rateLimitStatus().limited);
83
105
  const cacheDirectory = join(homedir(), '.agents-relay');
84
106
  const cacheFile = join(cacheDirectory, 'dashboard-jobs.json');
85
107
  try {
@@ -88,11 +110,17 @@ export async function runDaemon(argv) {
88
110
  cachedOverviews = parsed;
89
111
  }
90
112
  catch { /* first run or invalid cache */ }
91
- const refreshAll = async () => {
113
+ const refreshAll = async (full = false) => {
92
114
  if (client.rateLimitStatus().limited)
93
115
  return cachedOverviews;
116
+ const scanRepositories = full ? repositories : recoveryScanRepositories(repositories, cachedOverviews, dormantProbeIndex++);
117
+ if (scanRepositories.length === 0)
118
+ return cachedOverviews;
94
119
  try {
95
- cachedOverviews = dashboardManagedJobs(await aggregateManagedGitHubJobs(client, repositories, trusted));
120
+ githubReadPaths.workspaceScans += 1;
121
+ const refreshed = await aggregateManagedGitHubJobs(client, scanRepositories, trusted);
122
+ const scanned = new Set(scanRepositories);
123
+ cachedOverviews = dashboardManagedJobs([...cachedOverviews.filter(item => !scanned.has(item.job.repository)), ...refreshed]);
96
124
  try {
97
125
  await mkdir(cacheDirectory, { recursive: true });
98
126
  await writeFile(cacheFile, JSON.stringify(cachedOverviews), 'utf8');
@@ -105,7 +133,7 @@ export async function runDaemon(argv) {
105
133
  return cachedOverviews;
106
134
  }
107
135
  };
108
- const overviews = async (forceRefresh = false) => forceRefresh ? refreshAll() : cachedOverviews;
136
+ const overviews = async (forceRefresh = false) => forceRefresh ? refreshAll(true) : cachedOverviews;
109
137
  const persistCache = async () => {
110
138
  try {
111
139
  await mkdir(cacheDirectory, { recursive: true });
@@ -113,13 +141,31 @@ export async function runDaemon(argv) {
113
141
  }
114
142
  catch { /* cache is best effort */ }
115
143
  };
144
+ const pullRequestRefreshFlights = new Map();
116
145
  const refreshPullRequest = async (repository, prNumber) => {
117
- const refreshed = await loadManagedGitHubPullRequestGraphql(client, repository, prNumber, trusted);
118
- cachedOverviews = cachedOverviews.filter(item => item.job.repository !== repository || item.job.prNumber !== prNumber);
119
- cachedOverviews.push(...refreshed);
120
- cachedOverviews = dashboardManagedJobs(cachedOverviews);
121
- await persistCache();
122
- return refreshed;
146
+ if (client.rateLimitStatus().limited)
147
+ return cachedOverviews.filter(item => item.job.repository === repository && item.job.prNumber === prNumber);
148
+ const key = `${repository}#${prNumber}`;
149
+ const existing = pullRequestRefreshFlights.get(key);
150
+ if (existing)
151
+ return existing;
152
+ const flight = (async () => {
153
+ githubReadPaths.prRefreshes += 1;
154
+ const refreshed = await loadManagedGitHubPullRequestGraphql(client, repository, prNumber, trusted);
155
+ cachedOverviews = cachedOverviews.filter(item => item.job.repository !== repository || item.job.prNumber !== prNumber);
156
+ cachedOverviews.push(...refreshed);
157
+ cachedOverviews = dashboardManagedJobs(cachedOverviews);
158
+ await persistCache();
159
+ return refreshed;
160
+ })();
161
+ pullRequestRefreshFlights.set(key, flight);
162
+ try {
163
+ return await flight;
164
+ }
165
+ finally {
166
+ if (pullRequestRefreshFlights.get(key) === flight)
167
+ pullRequestRefreshFlights.delete(key);
168
+ }
123
169
  };
124
170
  const initial = cachedOverviews;
125
171
  const fallback = initial[0]?.job;
@@ -170,6 +216,9 @@ export async function runDaemon(argv) {
170
216
  const cached = cachedOverviews.find(item => item.job.id === jobId);
171
217
  if (!cached)
172
218
  return null;
219
+ const cachedJob = () => ({ ...cached.job, githubState: cached.githubState, runtimeStatus: deriveJobRuntimeStatus(cached.job, cached.githubState), mergedAt: cached.mergedAt, closedAt: cached.closedAt, draft: cached.draft });
220
+ if (client.rateLimitStatus().limited)
221
+ return cachedJob();
173
222
  try {
174
223
  const refreshed = (await refreshPullRequest(cached.job.repository, cached.job.prNumber)).find(item => item.job.id === jobId);
175
224
  if (!refreshed)
@@ -178,10 +227,10 @@ export async function runDaemon(argv) {
178
227
  }
179
228
  catch (error) {
180
229
  process.stderr.write(`warning: selected job refresh unavailable: ${error instanceof Error ? error.message : String(error)}\n`);
181
- return null;
230
+ return cachedJob();
182
231
  }
183
232
  };
184
- const server = serveDashboard(dashboardStore, Number(value(argv, '--port', String(SERVICE_DEFAULTS.port))), bus, Number(value(argv, '--refresh-ms', String(SERVICE_DEFAULTS.dashboardRefreshMs))), fallback?.id, usageRegistry, overviews, webhook, () => client.rateLimitStatus(), exactJob, {
233
+ const server = serveDashboard(dashboardStore, Number(value(argv, '--port', String(SERVICE_DEFAULTS.port))), bus, Number(value(argv, '--refresh-ms', String(SERVICE_DEFAULTS.dashboardRefreshMs))), fallback?.id, usageRegistry, overviews, webhook, () => ({ ...client.rateLimitStatus(), readPaths: { ...githubReadPaths } }), () => client.rateLimitSnapshot(), exactJob, {
185
234
  start: workspaceJobAction('start'),
186
235
  reconcile: workspaceJobAction('reconcile'),
187
236
  cancel: workspaceJobAction('cancel'),
@@ -207,7 +256,7 @@ export async function runDaemon(argv) {
207
256
  process.once('SIGINT', stop);
208
257
  process.once('SIGTERM', stop);
209
258
  console.log('Dashboard listening; repository worker pool active');
210
- void refreshAll()
259
+ void refreshAll(true)
211
260
  .then(items => pool.reconcileKnown(items))
212
261
  .catch(error => process.stderr.write(`warning: startup reconciliation unavailable: ${error instanceof Error ? error.message : String(error)}\n`));
213
262
  }
@@ -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
  }
@@ -5,7 +5,7 @@ function immutableTaskDefinition(task) {
5
5
  } : undefined;
6
6
  return {
7
7
  jobId: task.jobId, id: task.id, priority: task.priority, storySize: task.storySize ?? 'M', projectName: task.projectName, agentName: task.agentName, agentGraph: task.agentGraph,
8
- kind: task.kind ?? 'work', planningDecisionId: task.planningDecisionId, planningSource: task.planningSource, planningRound: task.planningRound, parentTaskId: task.parentTaskId, dependencies: task.dependencies, capabilities: task.capabilities, workerRequirements: task.workerRequirements, workerSelection: task.workerSelection, adapter: task.adapter,
8
+ kind: task.kind ?? 'work', planningDecisionId: task.planningDecisionId, planningSource: task.planningSource, planningRound: task.planningRound, plannerGuidance: task.plannerGuidance, manualPlanning: task.manualPlanning, parentTaskId: task.parentTaskId, dependencies: task.dependencies, capabilities: task.capabilities, workerRequirements: task.workerRequirements, workerSelection: task.workerSelection, adapter: task.adapter,
9
9
  input: task.input, attachments: task.attachments, output: task.output, routing, chatgptTabClosePolicy: task.chatgptTabClosePolicy ?? 'after-start', continuation: task.continuation ?? null, maxAttempts: task.maxAttempts, timeoutMs: task.timeoutMs
10
10
  };
11
11
  }
package/dist/store.js CHANGED
@@ -234,12 +234,47 @@ export class GhApiClient {
234
234
  cooldownUntil = 0;
235
235
  rateLimitFailures = 0;
236
236
  installationToken = null;
237
+ rateLimitCache = null;
238
+ rateLimitFlight = null;
239
+ rateLimitCacheTtlMs = 5 * 60_000;
240
+ apiDiagnostics = { rest: 0, graphql: 0, rateLimit: 0 };
237
241
  constructor(appAuth = null) {
238
242
  this.appAuth = appAuth;
239
243
  }
240
244
  rateLimitStatus() {
241
245
  const limited = Date.now() < this.cooldownUntil;
242
- return { limited, cooldownUntil: limited ? new Date(this.cooldownUntil).toISOString() : null };
246
+ return { limited, cooldownUntil: limited ? new Date(this.cooldownUntil).toISOString() : null, calls: { ...this.apiDiagnostics } };
247
+ }
248
+ async rateLimitSnapshot(force = false) {
249
+ const now = Date.now();
250
+ if (!force && this.rateLimitCache && now - this.rateLimitCache.cachedAt < this.rateLimitCacheTtlMs)
251
+ return this.rateLimitCache.snapshot;
252
+ if (this.rateLimitFlight)
253
+ return this.rateLimitFlight;
254
+ if (this.rateLimitStatus().limited && this.rateLimitCache)
255
+ return this.rateLimitCache.snapshot;
256
+ const flight = (async () => {
257
+ this.apiDiagnostics.rateLimit += 1;
258
+ const stdout = await this.spawnGh(['api', 'rate_limit'], undefined, await this.commandEnv());
259
+ const parsed = JSON.parse(stdout);
260
+ const resources = parsed.resources;
261
+ const bucket = (value) => {
262
+ const item = value;
263
+ const limit = Number(item?.limit), used = Number(item?.used), remaining = Number(item?.remaining), reset = Number(item?.reset);
264
+ return [limit, used, remaining, reset].every(Number.isFinite) ? { limit, used, remaining, reset } : undefined;
265
+ };
266
+ const snapshot = { observedAt: new Date().toISOString(), resources: { core: bucket(resources?.core), graphql: bucket(resources?.graphql), search: bucket(resources?.search) } };
267
+ this.rateLimitCache = { cachedAt: Date.now(), snapshot };
268
+ return snapshot;
269
+ })();
270
+ this.rateLimitFlight = flight;
271
+ try {
272
+ return await flight;
273
+ }
274
+ finally {
275
+ if (this.rateLimitFlight === flight)
276
+ this.rateLimitFlight = null;
277
+ }
243
278
  }
244
279
  assertAvailable() {
245
280
  if (Date.now() < this.cooldownUntil)
@@ -322,6 +357,7 @@ export class GhApiClient {
322
357
  }
323
358
  async graphql(query, variables = {}) {
324
359
  this.assertAvailable();
360
+ this.apiDiagnostics.graphql += 1;
325
361
  const payload = JSON.stringify({ query, variables });
326
362
  const stdout = await this.spawnGh(['api', 'graphql', '--input', '-'], payload, await this.commandEnv());
327
363
  const parsed = JSON.parse(stdout);
@@ -334,6 +370,7 @@ export class GhApiClient {
334
370
  }
335
371
  async request(path, init = {}) {
336
372
  this.assertAvailable();
373
+ this.apiDiagnostics.rest += 1;
337
374
  const args = ['api', path];
338
375
  const method = String(init.method ?? 'GET');
339
376
  if (method !== 'GET')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-relay",
3
- "version": "1.0.125",
3
+ "version": "1.0.127",
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
 
@@ -96,11 +96,11 @@ ChatGPT thread identity is diagnostic only. It is not persisted as a resumable t
96
96
 
97
97
  ## Autonomous planner
98
98
 
99
- Direct orchestrator-created Tasks do not require a Plan. The orchestrator may create the initial batch with `task create`; those Tasks remain top-level unless the orchestrator explicitly gives them normal task parentage.
99
+ Direct orchestrator-created Tasks do not require a Plan. The orchestrator may create the initial batch with `task create`; those Tasks remain top-level unless the orchestrator explicitly gives them normal task parentage. When planner-driven decomposition is desired instead, use `agents-relay plan --repo OWNER/REPO --pr N --id JOB_ID [--prompt TEXT | --prompt-file PATH|-]`.
100
100
 
101
- For autonomous continuation, Relay invokes the planner agent as a pure decision function. The planner receives durable Job/Task state, previous planner rounds, and resolved execution context, then returns structured `{ objective_status, assessment, next_tasks }` output. The planner does not call Relay CLI and does not persist Tasks itself.
101
+ For explicit `plan` and autonomous continuation, Relay invokes the same planner agent as a pure decision function. The planner receives durable Job/Task state, previous planner rounds, and resolved execution context, then returns structured `{ objective_status, assessment, next_tasks }` output. The planner does not call Relay CLI and does not persist Tasks itself.
102
102
 
103
- Each successful planner invocation is one real planner round. Relay persists that planner result, then creates the returned Tasks with lineage back to that planner round. Only planner execution creates planner rounds; Relay never manufactures synthetic `plan-*` records for direct Task creation. In the dashboard, planner rounds are organizational/grouping nodes for the Tasks they produced.
103
+ Each successful planner invocation is one real `plan-N` planning round. Relay persists that planner result, then creates the returned Tasks with lineage back to that planning round. Historical `planner-N` records remain readable. Only planner execution creates planner rounds; Relay never manufactures synthetic `plan-*` records for direct Task creation. In the dashboard, planner rounds are organizational/grouping nodes for the Tasks they produced.
104
104
 
105
105
  A zero-task autonomous Job remains idle. After a direct orchestrator-created initial batch completes, Relay may start the first real planner round. Fixed Jobs do not automatically create planner rounds.
106
106
 
@@ -139,13 +139,14 @@ Final-review routing follows the same factual worker-selection boundary as execu
139
139
  The public CLI resource surface is:
140
140
 
141
141
  - `job create|update|show|list|merge|cancel`
142
- - `task create|update|show|list|retry|cancel`
142
+ - `task create|update|show|list|complete|retry|cancel`
143
+ - `plan`
143
144
 
144
- `job create` persists only durable Job state. It never creates executable work. `task create` creates a real executable Task directly under the Job; direct orchestrator-created Tasks are not wrapped in synthetic planner Tasks and have no planner-round provenance.
145
+ `job create` persists only durable Job state. It never creates executable work. `task create` creates a real executable Task directly under the Job; direct orchestrator-created Tasks are not wrapped in synthetic planner Tasks and have no planner-round provenance. `plan` is the explicit public operation for invoking the Planner against an existing Job; it is not generic CRUD for planner records.
145
146
 
146
- Planner rounds are internal planner output, not a general public CLI resource. Relay invokes the planner agent, validates its structured result, persists that invocation as one planner round, then materializes `next_tasks` under that planner round. The planner agent itself does not call Relay CLI or persist Tasks. Only an actual planner invocation creates a planner round.
147
+ Relay invokes the planner agent, validates its structured result, persists that invocation as one real `plan-N` round, then materializes `next_tasks` under that planning round. `--prompt` / `--prompt-file` add caller guidance to the durable planning input without replacing the Job objective. The planner agent itself does not call Relay CLI or persist Tasks. Only an actual planner invocation creates a planning round. Historical `planner-N` rounds remain readable and participate in round numbering.
147
148
 
148
- A zero-task autonomous Job stays idle. An autonomous Job may begin with one or more direct orchestrator-created Tasks and no Plan. After that direct batch completes, Relay may create the first real planner round. Later planner rounds continue until the objective is satisfied. Fixed Jobs never create autonomous planner rounds.
149
+ A zero-task autonomous Job stays idle under ordinary reconciliation, but an explicit `plan` command can start it immediately. An autonomous Job may also begin with one or more direct orchestrator-created Tasks and no Plan. After that direct batch completes, Relay may create the next real planning round through the same round-construction/execution path used by manual `plan`. Later rounds continue until the objective is satisfied. Fixed Jobs never create autonomous planner rounds automatically.
149
150
 
150
151
  Planner-produced Tasks obey this same contract, including explicit `storySize`.
151
152