agents-relay 1.0.126 → 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 +18 -6
- package/dist/cli-help.js +11 -1
- package/dist/cli.js +14 -1
- package/dist/dashboard-html.js +3 -1
- package/dist/dashboard.js +38 -14
- package/dist/manual-plan.js +34 -0
- package/dist/planner.js +39 -1
- package/dist/pool.js +22 -3
- package/dist/reconciler.js +11 -15
- package/dist/relayd.js +62 -13
- package/dist/store-task-definition.js +1 -1
- package/dist/store.js +38 -1
- package/package.json +1 -1
- package/skills/agents-relay/SKILL.md +3 -3
- package/skills/agents-relay/contracts/task-job-contract.md +5 -4
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
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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|status|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,6 +8,7 @@ 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
|
|
@@ -126,6 +127,15 @@ Required: --task-id ID and the normal Job storage options.`,
|
|
|
126
127
|
'task cancel': `Cancel one durable Task.
|
|
127
128
|
|
|
128
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."`,
|
|
129
139
|
blocker: `Manage durable cross-Job blocker dependencies.
|
|
130
140
|
|
|
131
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'),
|
|
@@ -637,6 +637,19 @@ async function main() {
|
|
|
637
637
|
}
|
|
638
638
|
const loaded = await storeFor(args);
|
|
639
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
|
+
}
|
|
640
653
|
if (command === 'agent-register') {
|
|
641
654
|
const now = new Date().toISOString();
|
|
642
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)
|
package/dist/dashboard-html.js
CHANGED
|
@@ -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
|
-
|
|
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 &&
|
|
110
|
-
const exact = await
|
|
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
|
-
|
|
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' ?
|
|
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
|
-
|
|
211
|
-
res.
|
|
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.
|
|
215
|
-
|
|
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' ?
|
|
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
|
-
|
|
232
|
-
res.
|
|
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.
|
|
236
|
-
|
|
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
|
}
|
|
@@ -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
|
-
|
|
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`);
|
package/dist/reconciler.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
|
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
|
}
|
|
@@ -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
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
|