agents-relay 1.0.40 → 1.0.42
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 +14 -0
- package/dist/adapters.js +1 -1
- package/dist/cli.js +4 -3
- package/dist/dashboard.js +2 -1
- package/dist/planner.js +25 -4
- package/dist/reconciler.js +51 -3
- package/dist/relayd.js +2 -2
- package/dist/reviewer-routing.js +64 -0
- package/dist/store.js +2 -2
- package/dist/usage.js +4 -0
- package/dist/worker-routing.js +76 -0
- package/package.json +2 -1
- package/skills/agents-relay/agents/planner.agent.md +4 -3
- package/skills/agents-relay/contracts/task-job-contract.md +14 -1
package/README.md
CHANGED
|
@@ -88,6 +88,20 @@ node dist/cli.js reconcile --file .agents-relay.json
|
|
|
88
88
|
npx agents-relay serve --file .agents-relay.json
|
|
89
89
|
~~~
|
|
90
90
|
|
|
91
|
+
### Opt-in Temporary Chat quality checks
|
|
92
|
+
|
|
93
|
+
The normal `npm test` suite does not open a browser or send ChatGPT messages.
|
|
94
|
+
For an explicit live check of the one-shot Temporary Chat path, use an
|
|
95
|
+
authenticated browser session and run:
|
|
96
|
+
|
|
97
|
+
~~~sh
|
|
98
|
+
npm run test:chatgpt-temp-quality
|
|
99
|
+
~~~
|
|
100
|
+
|
|
101
|
+
This small live suite covers two Temporary Chat submission gates: a normal text
|
|
102
|
+
prompt and a prompt with an attachment. It does not exercise worker lifecycle
|
|
103
|
+
events, durable-thread reuse, resume, or retry behavior.
|
|
104
|
+
|
|
91
105
|
Open the dashboard on localhost port 8787. The marker format is intentionally public and append-friendly; the CLI uses GitHubStore with the authenticated gh client in operational mode.
|
|
92
106
|
|
|
93
107
|
reconcile leases and launches ready tasks without waiting inside the runtime scheduler; the CLI waits only long enough for its launched workers to persist results. agents-relayd (or the equivalent npx agents-relay serve) is the watchdog/dashboard mode. Model-backed workers require --events nats; the event transport carries authoritative terminal worker events as well as wake/progress traffic. See [docs/service.md](docs/service.md). --file PATH is explicit local demo/test mode only.
|
package/dist/adapters.js
CHANGED
|
@@ -252,7 +252,7 @@ export class ChatGptAdapter {
|
|
|
252
252
|
if (controller.signal.aborted)
|
|
253
253
|
throw new Error('browser-worker execution aborted');
|
|
254
254
|
const policy = chatGptTabClosePolicy(task.chatgptTabClosePolicy, this.tabClosePolicy);
|
|
255
|
-
const run = (this.runner ?? directBrowserWorkerRunner(runtime.root))(runtime.definition, { prompt: task.input, tabClosePolicy: policy }, route, controller.signal);
|
|
255
|
+
const run = (this.runner ?? directBrowserWorkerRunner(runtime.root))(runtime.definition, { prompt: task.input, ...(task.attachments?.length ? { files: task.attachments } : {}), tabClosePolicy: policy }, route, controller.signal);
|
|
256
256
|
try {
|
|
257
257
|
const output = await (run.submitted ?? run.promise);
|
|
258
258
|
const response = parseBrowserWorkerResponse(output);
|
package/dist/cli.js
CHANGED
|
@@ -356,6 +356,7 @@ function workerTaskFromArgs(job, args, now, requireInput = false) {
|
|
|
356
356
|
adapter,
|
|
357
357
|
chatgptTabClosePolicy: closePolicy,
|
|
358
358
|
input: input || 'true',
|
|
359
|
+
attachments: arg(args, '--attachments').split(',').filter(Boolean),
|
|
359
360
|
output: outputKind === 'file' ? { kind: 'file', path: outputPath } : { kind: 'task_pr' },
|
|
360
361
|
routing,
|
|
361
362
|
continuation: continuation(args),
|
|
@@ -507,16 +508,16 @@ async function runJobCommand(action, args) {
|
|
|
507
508
|
console.log(JSON.stringify({ repository: repo, pr: result.pr.number, job: result.job.id, executionMode: result.job.executionMode, state: result.job.state, task: start ? arg(args, '--task-id', 'initial') : undefined }, null, 2));
|
|
508
509
|
}
|
|
509
510
|
export function runtimePlanner(args) { const plannerCommand = arg([...args], '--planner-command'); const modelRuntime = arg([...args], '--codex', 'codex'); return plannerCommand ? new CommandObjectivePlanner(plannerCommand) : new AgentObjectivePlanner(modelRuntime); }
|
|
510
|
-
export function runtimeReviewer() { return {
|
|
511
|
-
export function runtime(store, args, bus) { const emit = (event) => { process.stderr.write(`${JSON.stringify(event)}\n`); }; const modelRuntime = arg(args, '--codex', 'codex'); const policy = chatGptTabClosePolicy(args); const planner = runtimePlanner(args); return new Reconciler(store, { owner: arg(args, '--owner', `cli-${process.pid}`), maxConcurrent: Number(arg(args, '--concurrency', '4')), leaseMs: Number(arg(args, '--lease-ms', '300000')), adapters: [new CodexAdapter(modelRuntime), new ChatGptAdapter({ tabClosePolicy: policy })], continuations: [new CodexThreadContinuation(modelRuntime), new CommandContinuation(), new WebhookContinuation()], planner, reviewer: runtimeReviewer(), eventBus: bus, emit, chatgptTabClosePolicy: policy, projectPathForJob: job => resolveManagedProjectPath(job.repository, arg(args, '--project-path'), arg(args, '--workspace-root') || defaultWorkspaceRoot()) }); }
|
|
511
|
+
export function runtimeReviewer() { return { requirements: { capabilities: ['model'] } }; }
|
|
512
|
+
export function runtime(store, args, bus, usageRegistry = codexAndZaiUsageRegistry()) { const emit = (event) => { process.stderr.write(`${JSON.stringify(event)}\n`); }; const modelRuntime = arg(args, '--codex', 'codex'); const policy = chatGptTabClosePolicy(args); const planner = runtimePlanner(args); return new Reconciler(store, { owner: arg(args, '--owner', `cli-${process.pid}`), maxConcurrent: Number(arg(args, '--concurrency', '4')), leaseMs: Number(arg(args, '--lease-ms', '300000')), adapters: [new CodexAdapter(modelRuntime), new ChatGptAdapter({ tabClosePolicy: policy })], continuations: [new CodexThreadContinuation(modelRuntime), new CommandContinuation(), new WebhookContinuation()], planner, usageSnapshotProvider: () => usageRegistry.snapshot(), reviewer: runtimeReviewer(), eventBus: bus, emit, chatgptTabClosePolicy: policy, projectPathForJob: job => resolveManagedProjectPath(job.repository, arg(args, '--project-path'), arg(args, '--workspace-root') || defaultWorkspaceRoot()) }); }
|
|
512
513
|
export async function createService(args) {
|
|
513
514
|
const loaded = await storeFor(args);
|
|
514
515
|
const upstream = eventBus(args);
|
|
515
516
|
const bus = upstream ? new ServiceEventBus(upstream) : undefined;
|
|
516
|
-
const reconciler = runtime(loaded.store, args, bus);
|
|
517
517
|
const requestedUsageDays = Number(arg(args, '--usage-window-days', '1'));
|
|
518
518
|
const usageWindowDays = Number.isInteger(requestedUsageDays) && requestedUsageDays > 0 ? requestedUsageDays : 1;
|
|
519
519
|
const usageRegistry = codexAndZaiUsageRegistry(undefined, usageWindowDays);
|
|
520
|
+
const reconciler = runtime(loaded.store, args, bus, usageRegistry);
|
|
520
521
|
const repo = arg(args, '--repo');
|
|
521
522
|
let overviewProvider;
|
|
522
523
|
if (repo) {
|
package/dist/dashboard.js
CHANGED
|
@@ -2,6 +2,7 @@ import { createServer } from 'node:http';
|
|
|
2
2
|
import { readFile } from 'node:fs/promises';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
|
+
import { readUsageSnapshot } from './usage.js';
|
|
5
6
|
import { DeliveryDeduper, parseGitHubWebhook, verifyGitHubWebhook } from './github-webhook.js';
|
|
6
7
|
const GITHUB_REPOSITORY_PATTERN = /^([A-Za-z0-9](?:[A-Za-z0-9-]{0,36}[A-Za-z0-9])?)\/([A-Za-z0-9._-]{1,100})$/;
|
|
7
8
|
const SSE_HEARTBEAT_MS = 15000;
|
|
@@ -363,7 +364,7 @@ export function serveDashboard(store, port = 8787, bus, refreshMs = 300000, jobI
|
|
|
363
364
|
const usage = async () => {
|
|
364
365
|
if (!usageRegistry)
|
|
365
366
|
throw new Error('Account usage telemetry is unavailable');
|
|
366
|
-
return JSON.stringify(await usageRegistry
|
|
367
|
+
return JSON.stringify(await readUsageSnapshot(usageRegistry));
|
|
367
368
|
};
|
|
368
369
|
const server = createServer(async (req, res) => {
|
|
369
370
|
if (req.url === '/') {
|
package/dist/planner.js
CHANGED
|
@@ -9,7 +9,7 @@ export function planningSpec(task) {
|
|
|
9
9
|
throw new Error(`Initial task ${task.id} requires an output contract`);
|
|
10
10
|
if (!['codex', 'chatgpt', 'orchestrator'].includes(task.adapter))
|
|
11
11
|
throw new Error(`Initial task ${task.id} requires a supported adapter`);
|
|
12
|
-
return { id: task.id, input: task.input, priority: task.priority, storySize: task.storySize, projectName: task.projectName, agentName: task.agentName, dependencies: task.dependencies, capabilities: task.capabilities, adapter: task.adapter, output: task.output ?? { kind: 'task_pr' }, routing: task.routing, continuation: task.continuation, maxAttempts: task.maxAttempts, timeoutMs: task.timeoutMs };
|
|
12
|
+
return { id: task.id, input: task.input, priority: task.priority, storySize: task.storySize, projectName: task.projectName, agentName: task.agentName, dependencies: task.dependencies, capabilities: task.capabilities, workerRequirements: task.workerRequirements, adapter: task.adapter, output: task.output ?? { kind: 'task_pr' }, routing: task.routing, continuation: task.continuation, maxAttempts: task.maxAttempts, timeoutMs: task.timeoutMs };
|
|
13
13
|
}
|
|
14
14
|
export function orchestratorPlanningDecision(job, tasks, id, parentTaskId, round, source = 'orchestrator') {
|
|
15
15
|
const now = new Date().toISOString();
|
|
@@ -34,6 +34,26 @@ function stringArray(value, field, fallback = []) {
|
|
|
34
34
|
throw new Error(`Planner ${field} must be an array of non-empty strings`);
|
|
35
35
|
return [...value];
|
|
36
36
|
}
|
|
37
|
+
function workerRequirements(value, field) {
|
|
38
|
+
if (value === undefined || value === null)
|
|
39
|
+
return undefined;
|
|
40
|
+
const root = record(value);
|
|
41
|
+
const capabilities = stringArray(root.capabilities, `${field}.capabilities`);
|
|
42
|
+
const optional = (name) => optionalString(root[name], `${field}.${name}`);
|
|
43
|
+
const labelsValue = root.labels;
|
|
44
|
+
let labels;
|
|
45
|
+
if (labelsValue !== undefined) {
|
|
46
|
+
if (!labelsValue || typeof labelsValue !== 'object' || Array.isArray(labelsValue))
|
|
47
|
+
throw new Error(`Planner ${field}.labels must be an object`);
|
|
48
|
+
labels = {};
|
|
49
|
+
for (const [key, item] of Object.entries(labelsValue)) {
|
|
50
|
+
if (typeof item !== 'string' || item.length === 0)
|
|
51
|
+
throw new Error(`Planner ${field}.labels values must be non-empty strings`);
|
|
52
|
+
labels[key] = item;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return { capabilities, runtime: optional('runtime'), taskKind: optional('taskKind'), trustDomain: optional('trustDomain'), region: optional('region'), labels };
|
|
56
|
+
}
|
|
37
57
|
function rawValue(value) {
|
|
38
58
|
if (typeof value === 'string')
|
|
39
59
|
return JSON.stringify(value);
|
|
@@ -88,8 +108,8 @@ export function parsePlannerResult(value) {
|
|
|
88
108
|
if (typeof task.input !== 'string' || task.input.length === 0)
|
|
89
109
|
throw new Error(`Planner next_tasks[${index}].input is required`);
|
|
90
110
|
const adapter = task.adapter;
|
|
91
|
-
if (adapter
|
|
92
|
-
throw new Error(`Planner next_tasks[${index}].adapter must be codex or chatgpt`);
|
|
111
|
+
if (adapter !== undefined && !['codex', 'chatgpt'].includes(String(adapter)))
|
|
112
|
+
throw new Error(`Planner next_tasks[${index}].adapter must be codex or chatgpt when provided`);
|
|
93
113
|
const output = task.output === undefined ? undefined : record(task.output);
|
|
94
114
|
if (!output || (output.kind !== 'task_pr' && output.kind !== 'file'))
|
|
95
115
|
throw new Error(`Planner next_tasks[${index}].output is required`);
|
|
@@ -102,6 +122,7 @@ export function parsePlannerResult(value) {
|
|
|
102
122
|
const storySize = sizeValue;
|
|
103
123
|
const maxAttempts = task.maxAttempts === undefined ? 3 : task.maxAttempts;
|
|
104
124
|
const route = routing(task.routing, 'next_tasks[' + index + '].routing');
|
|
125
|
+
const requirements = workerRequirements(task.workerRequirements ?? task.requirements, 'next_tasks[' + index + '].workerRequirements');
|
|
105
126
|
const timeoutMs = task.timeoutMs === undefined ? taskOverallTimeoutMs(storySize, route?.reasoning) : task.timeoutMs;
|
|
106
127
|
if (typeof maxAttempts !== 'number' || !Number.isInteger(maxAttempts) || maxAttempts < 1)
|
|
107
128
|
throw new Error(`Planner next_tasks[${index}].maxAttempts is invalid`);
|
|
@@ -110,7 +131,7 @@ export function parsePlannerResult(value) {
|
|
|
110
131
|
return {
|
|
111
132
|
id, input: task.input, priority, storySize,
|
|
112
133
|
projectName: optionalString(task.projectName, 'projectName'), agentName: optionalString(task.agentName, 'agentName'),
|
|
113
|
-
dependencies: stringArray(task.dependencies, 'dependencies'), capabilities: stringArray(task.capabilities, 'capabilities'),
|
|
134
|
+
dependencies: stringArray(task.dependencies, 'dependencies'), capabilities: stringArray(task.capabilities, 'capabilities'), workerRequirements: requirements,
|
|
114
135
|
adapter: adapter, output: output, routing: route,
|
|
115
136
|
continuation: task.continuation, maxAttempts, timeoutMs
|
|
116
137
|
};
|
package/dist/reconciler.js
CHANGED
|
@@ -5,6 +5,8 @@ import { LaunchFailure, StartAckTimeout } from './adapters.js';
|
|
|
5
5
|
import { eventFor } from './events.js';
|
|
6
6
|
import { parsePlannerResult, plannerInput } from './planner.js';
|
|
7
7
|
import { executionContextPrompt, resolveExecutionContext } from './execution-context.js';
|
|
8
|
+
import { selectReviewer } from './reviewer-routing.js';
|
|
9
|
+
import { selectWorker, usageForProvider, workerCandidates } from './worker-routing.js';
|
|
8
10
|
const PRIORITY_RANK = { P0: 0, P1: 1, P2: 2, P3: 3 };
|
|
9
11
|
export function compareTaskPriority(a, b, jobPriority = 'P2') { const ar = PRIORITY_RANK[a.priority ?? jobPriority]; const br = PRIORITY_RANK[b.priority ?? jobPriority]; return ar - br || a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id); }
|
|
10
12
|
export function managedWorkerInput(job, task, context) {
|
|
@@ -130,6 +132,7 @@ export class Reconciler {
|
|
|
130
132
|
}
|
|
131
133
|
await this.recoverLostExecutions(job, now);
|
|
132
134
|
await this.recoverPlannerChildren(job);
|
|
135
|
+
await this.routeExecutableTasks(await this.store.load(jobId));
|
|
133
136
|
for (const task of job.tasks)
|
|
134
137
|
if (task.state === 'CANCELLED') {
|
|
135
138
|
this.live.get(task.id)?.execution.cancel();
|
|
@@ -221,19 +224,64 @@ export class Reconciler {
|
|
|
221
224
|
return;
|
|
222
225
|
const id = `final-review-${headSha.slice(0, 12)}`;
|
|
223
226
|
const now = new Date().toISOString();
|
|
224
|
-
const
|
|
227
|
+
const implementationTasks = job.tasks.filter(item => item.agentName !== 'reviewer' && !this.isSupersededTask(item));
|
|
228
|
+
const reviewerRouting = await this.selectReviewer(implementationTasks);
|
|
229
|
+
const task = { jobId: job.id, id, kind: 'work', priority: job.priority, storySize: 'M', projectName: job.title, agentName: 'reviewer', parentTaskId: null, dependencies: implementationTasks.map(item => item.id), capabilities: ['review'], workerSelection: reviewerRouting.selection, adapter: reviewerRouting.candidate.adapter, input: `Review PR #${job.prNumber} at head SHA ${headSha}. Objective: ${job.objective ?? job.description ?? job.title}. First publish task.started as the worker start acknowledgement with data.worker_owned=true, then verify the deterministic pre-review gates have passed and inspect the completed work. Emit review.started, then exactly one of review.approved, review.changes_required, or review.blocked with data.review.headSha set to ${headSha}. For review.changes_required, include every actionable finding in data.review.issues as an array of concise issue strings; Relay will create one durable fix task containing all findings. Do not modify code. When auto-merge is enabled, Relay will merge this exact reviewed head after review.approved.`, output: { kind: 'task_pr' }, routing: reviewerRouting.candidate.routing, continuation: null, continuationDeliveredAt: null, reviewHeadSha: headSha, state: 'QUEUED', attempt: 0, maxAttempts: this.options.reviewer?.maxAttempts ?? 2, leaseOwner: null, leaseExpiresAt: null, executionId: null, threadId: null, result: null, plannerResult: null, error: null, timeoutMs: this.options.reviewer?.timeoutMs ?? storySizeTimeoutMs('M'), createdAt: now, updatedAt: now };
|
|
225
230
|
await this.store.appendTask(task);
|
|
226
231
|
job.review = { outcome: 'pending', headSha, taskId: id, evidence: null, updatedAt: now };
|
|
227
232
|
job.state = 'RUNNING';
|
|
228
233
|
job.updatedAt = now;
|
|
229
234
|
await this.store.saveJob(job);
|
|
230
|
-
await this.emit(eventFor(job.id, id, null, 'review.created', 'queued', `Final review task ${id} created for ${headSha}`, 'orchestrator', { headSha, agentName: 'reviewer' }));
|
|
235
|
+
await this.emit(eventFor(job.id, id, null, 'review.created', 'queued', `Final review task ${id} created for ${headSha} using ${reviewerRouting.reason}`, 'orchestrator', { headSha, agentName: 'reviewer', reviewerRouting: { workerId: reviewerRouting.selection.workerId, reason: reviewerRouting.reason, selectedBy: reviewerRouting.selection.selectedBy } }));
|
|
231
236
|
const ready = schedule(await this.store.load(job.id));
|
|
232
237
|
await this.store.saveJob(ready);
|
|
233
238
|
const reviewTask = ready.tasks.find(item => item.id === id && item.state === 'READY');
|
|
234
239
|
if (reviewTask && (this.options.maxConcurrent ?? 4) > this.live.size + this.plannerLive.size)
|
|
235
240
|
await this.launch(ready, reviewTask);
|
|
236
241
|
}
|
|
242
|
+
async selectReviewer(implementationTasks) {
|
|
243
|
+
const configured = this.options.reviewer;
|
|
244
|
+
const registered = await this.store.listAgents();
|
|
245
|
+
const usage = this.options.usageSnapshotProvider ? await this.options.usageSnapshotProvider() : undefined;
|
|
246
|
+
const candidates = workerCandidates(this.options.adapters, registered, usage);
|
|
247
|
+
let constrained = candidates.filter(candidate => {
|
|
248
|
+
if (configured?.workerId && configured.workerId !== candidate.workerId)
|
|
249
|
+
return false;
|
|
250
|
+
if (configured?.adapter && configured.adapter !== candidate.adapter)
|
|
251
|
+
return false;
|
|
252
|
+
if (configured?.routing?.provider && configured.routing.provider !== candidate.routing.provider)
|
|
253
|
+
return false;
|
|
254
|
+
if (configured?.routing?.model && configured.routing.model !== candidate.routing.model)
|
|
255
|
+
return false;
|
|
256
|
+
return true;
|
|
257
|
+
}).map(candidate => ({ ...candidate, routing: configured?.routing ? { ...candidate.routing, ...configured.routing } : candidate.routing }));
|
|
258
|
+
// Explicit reviewer routing is a caller-owned hard constraint and remains
|
|
259
|
+
// readable in isolated/test runtimes that do not instantiate that adapter.
|
|
260
|
+
if (constrained.length === 0 && configured?.adapter && configured.routing) {
|
|
261
|
+
constrained = [{ workerId: configured.workerId ?? configured.adapter, adapter: configured.adapter, capabilities: ['model'], runtime: configured.adapter, routing: configured.routing }];
|
|
262
|
+
}
|
|
263
|
+
const result = selectReviewer(constrained, implementationTasks, configured?.requirements ?? { capabilities: ['model'] });
|
|
264
|
+
const resource = usageForProvider(usage, result.candidate.routing.provider);
|
|
265
|
+
result.selection.resourceHealthy = resource.healthy;
|
|
266
|
+
result.selection.resourceProvider = resource.healthy === undefined ? undefined : result.candidate.routing.provider;
|
|
267
|
+
result.selection.resourceUsedPercent = resource.usedPercent;
|
|
268
|
+
return result;
|
|
269
|
+
}
|
|
270
|
+
async routeExecutableTasks(job) {
|
|
271
|
+
const usage = this.options.usageSnapshotProvider ? await this.options.usageSnapshotProvider() : undefined;
|
|
272
|
+
const candidates = workerCandidates(this.options.adapters, await this.store.listAgents(), usage);
|
|
273
|
+
for (const task of job.tasks.filter(item => item.kind !== 'planner' && item.agentName !== 'reviewer' && item.adapter !== 'orchestrator' && ['QUEUED', 'READY', 'BLOCKED'].includes(item.state) && item.output && !item.workerSelection && (item.workerRequirements || item.planningSource === 'model-planner'))) {
|
|
274
|
+
const requirements = task.workerRequirements ?? { capabilities: task.capabilities.length > 0 ? task.capabilities : ['model'] };
|
|
275
|
+
const routed = selectWorker(candidates, requirements);
|
|
276
|
+
task.workerSelection = routed.selection;
|
|
277
|
+
task.adapter = routed.candidate.adapter;
|
|
278
|
+
task.routing = routed.candidate.routing;
|
|
279
|
+
task.timeoutMs = task.timeoutMs || taskOverallTimeoutMs(task.storySize ?? 'M', task.routing.reasoning);
|
|
280
|
+
task.updatedAt = new Date().toISOString();
|
|
281
|
+
await this.store.saveTask(task);
|
|
282
|
+
await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'worker.selected', 'queued', `Worker ${routed.selection.workerId} selected for task ${task.id}`, 'orchestrator', { workerSelection: routed.selection, workerRequirements: requirements }, { agent: 'relay', component: 'agents-relay' }));
|
|
283
|
+
}
|
|
284
|
+
}
|
|
237
285
|
isSupersededTask(task) {
|
|
238
286
|
return task.state === 'CANCELLED' && typeof task.error === 'string' && task.error.startsWith('Superseded ');
|
|
239
287
|
}
|
|
@@ -591,7 +639,7 @@ export class Reconciler {
|
|
|
591
639
|
}
|
|
592
640
|
plannerChild(job, planner, spec) {
|
|
593
641
|
const now = new Date().toISOString();
|
|
594
|
-
return { jobId: job.id, id: spec.id, kind: 'work', priority: spec.priority ?? job.priority, storySize: spec.storySize ?? 'M', projectName: spec.projectName, agentName: spec.agentName, parentTaskId: planner.id, dependencies: spec.dependencies ?? [], capabilities: spec.capabilities ?? [],
|
|
642
|
+
return { jobId: job.id, id: spec.id, kind: 'work', priority: spec.priority ?? job.priority, storySize: spec.storySize ?? 'M', projectName: spec.projectName, agentName: spec.agentName, parentTaskId: planner.id, dependencies: spec.dependencies ?? [], capabilities: spec.capabilities ?? [], workerRequirements: spec.workerRequirements, adapter: 'codex', input: spec.input, output: spec.output, continuation: spec.continuation ?? null, continuationDeliveredAt: null, state: 'QUEUED', attempt: 0, maxAttempts: spec.maxAttempts ?? 3, leaseOwner: null, leaseExpiresAt: null, executionId: null, threadId: null, result: null, plannerResult: null, error: null, timeoutMs: spec.timeoutMs ?? taskOverallTimeoutMs(spec.storySize ?? 'M'), createdAt: now, updatedAt: now, planningDecisionId: planner.id, planningSource: planner.planningSource ?? 'model-planner', planningRound: planner.planningRound ?? 1 };
|
|
595
643
|
}
|
|
596
644
|
clearExecution(taskId, executionId) {
|
|
597
645
|
const live = this.live.get(taskId);
|
package/dist/relayd.js
CHANGED
|
@@ -69,7 +69,8 @@ export async function runDaemon(argv) {
|
|
|
69
69
|
const repositories = normalized.repository ? [normalized.repository] : discoveredRepositories.map(item => item.repository);
|
|
70
70
|
const repositoryPaths = new Map(discoveredRepositories.map(item => [item.repository, item.path]));
|
|
71
71
|
const explicitProjectPath = value(argv, '--project-path');
|
|
72
|
-
const
|
|
72
|
+
const usageRegistry = codexAndZaiUsageRegistry();
|
|
73
|
+
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(), eventBus: bus, reviewer: runtimeReviewer(), projectPathForJob: job => repositoryPaths.get(job.repository) ?? resolveManagedProjectPath(job.repository, explicitProjectPath, workspaceRoot) });
|
|
73
74
|
if (repositories.length === 0)
|
|
74
75
|
throw new Error(`No Git repositories with GitHub origins found under ${normalized.workspaceRoot ?? defaultWorkspaceRoot()}`);
|
|
75
76
|
const pool = new RepositoryWorkerPool(client, repositories, trusted, concurrency, makeReconciler);
|
|
@@ -157,7 +158,6 @@ export async function runDaemon(argv) {
|
|
|
157
158
|
return null;
|
|
158
159
|
}
|
|
159
160
|
};
|
|
160
|
-
const usageRegistry = codexAndZaiUsageRegistry();
|
|
161
161
|
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);
|
|
162
162
|
await once(server, 'listening');
|
|
163
163
|
const reconcileSafely = async () => {
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
function satisfies(candidate, requirements) {
|
|
2
|
+
if (candidate.availability && candidate.availability !== 'available')
|
|
3
|
+
return false;
|
|
4
|
+
if (candidate.resourceHealthy === false)
|
|
5
|
+
return false;
|
|
6
|
+
if (!requirements)
|
|
7
|
+
return true;
|
|
8
|
+
if (!requirements.capabilities.every(capability => candidate.capabilities.includes(capability)))
|
|
9
|
+
return false;
|
|
10
|
+
if (requirements.runtime && candidate.runtime !== requirements.runtime)
|
|
11
|
+
return false;
|
|
12
|
+
if (requirements.taskKind && candidate.taskKind !== requirements.taskKind)
|
|
13
|
+
return false;
|
|
14
|
+
if (requirements.trustDomain && !candidate.trustDomains?.includes(requirements.trustDomain))
|
|
15
|
+
return false;
|
|
16
|
+
if (requirements.region && candidate.region !== requirements.region)
|
|
17
|
+
return false;
|
|
18
|
+
if (requirements.labels && Object.entries(requirements.labels).some(([key, value]) => candidate.labels?.[key] !== value))
|
|
19
|
+
return false;
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
function producedWorkerIds(tasks) {
|
|
23
|
+
return new Set(tasks
|
|
24
|
+
.filter(task => task.agentName !== 'reviewer')
|
|
25
|
+
.map(task => task.workerSelection?.workerId ?? task.adapter));
|
|
26
|
+
}
|
|
27
|
+
function producedRoutes(tasks) {
|
|
28
|
+
return new Set(tasks
|
|
29
|
+
.filter(task => task.agentName !== 'reviewer' && task.routing)
|
|
30
|
+
.map(task => `${task.routing?.provider}\0${task.routing?.model}`));
|
|
31
|
+
}
|
|
32
|
+
/** Select a review worker only after hard gates, then prefer execution diversity. */
|
|
33
|
+
export function selectReviewer(candidates, implementationTasks, requirements) {
|
|
34
|
+
const eligible = candidates.filter(candidate => satisfies(candidate, requirements));
|
|
35
|
+
if (eligible.length === 0)
|
|
36
|
+
throw new Error('No eligible reviewer worker satisfies the declared requirements');
|
|
37
|
+
const usedWorkers = producedWorkerIds(implementationTasks);
|
|
38
|
+
const usedRoutes = producedRoutes(implementationTasks);
|
|
39
|
+
const ranked = eligible.map((candidate, index) => ({
|
|
40
|
+
candidate,
|
|
41
|
+
index,
|
|
42
|
+
differentWorker: !usedWorkers.has(candidate.workerId),
|
|
43
|
+
differentRoute: !usedRoutes.has(`${candidate.routing.provider}\0${candidate.routing.model}`),
|
|
44
|
+
})).sort((left, right) => {
|
|
45
|
+
if (left.differentWorker !== right.differentWorker)
|
|
46
|
+
return left.differentWorker ? -1 : 1;
|
|
47
|
+
if (left.differentRoute !== right.differentRoute)
|
|
48
|
+
return left.differentRoute ? -1 : 1;
|
|
49
|
+
return left.index - right.index;
|
|
50
|
+
});
|
|
51
|
+
const selected = ranked[0];
|
|
52
|
+
const reason = selected.differentWorker ? 'worker-diversity' : selected.differentRoute ? 'provider-model-diversity' : 'eligible-fallback';
|
|
53
|
+
return {
|
|
54
|
+
candidate: selected.candidate,
|
|
55
|
+
selection: {
|
|
56
|
+
workerId: selected.candidate.workerId,
|
|
57
|
+
adapter: selected.candidate.adapter,
|
|
58
|
+
capabilities: [...selected.candidate.capabilities],
|
|
59
|
+
selectedBy: `worker-router:reviewer-diversity:${reason}`,
|
|
60
|
+
selectedAt: new Date().toISOString(),
|
|
61
|
+
},
|
|
62
|
+
reason,
|
|
63
|
+
};
|
|
64
|
+
}
|
package/dist/store.js
CHANGED
|
@@ -82,8 +82,8 @@ function immutableTaskDefinition(task) {
|
|
|
82
82
|
} : undefined;
|
|
83
83
|
return {
|
|
84
84
|
jobId: task.jobId, id: task.id, priority: task.priority, storySize: task.storySize ?? 'M', projectName: task.projectName, agentName: task.agentName,
|
|
85
|
-
kind: task.kind ?? 'work', planningDecisionId: task.planningDecisionId, planningSource: task.planningSource, planningRound: task.planningRound, parentTaskId: task.parentTaskId, dependencies: task.dependencies, capabilities: task.capabilities, adapter: task.adapter,
|
|
86
|
-
input: task.input, output: task.output, routing, chatgptTabClosePolicy: task.chatgptTabClosePolicy ?? 'after-start', continuation: task.continuation ?? null, maxAttempts: task.maxAttempts, timeoutMs: task.timeoutMs
|
|
85
|
+
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,
|
|
86
|
+
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
|
|
87
87
|
};
|
|
88
88
|
}
|
|
89
89
|
function sameTaskDefinition(left, right) {
|
package/dist/usage.js
CHANGED
|
@@ -348,6 +348,10 @@ export class UsageRegistry {
|
|
|
348
348
|
};
|
|
349
349
|
}
|
|
350
350
|
}
|
|
351
|
+
/** Read one fresh, on-demand snapshot from the shared usage source. */
|
|
352
|
+
export function readUsageSnapshot(source) {
|
|
353
|
+
return source.snapshot();
|
|
354
|
+
}
|
|
351
355
|
export class UnavailableUsageReader {
|
|
352
356
|
reason;
|
|
353
357
|
now;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
export function usageForProvider(snapshot, provider) {
|
|
2
|
+
const usage = snapshot?.providers.find(item => item.id === provider)?.usage;
|
|
3
|
+
if (!usage || !usage.available)
|
|
4
|
+
return {};
|
|
5
|
+
const usedPercent = Math.max(usage.fiveHour?.usedPercent ?? 0, usage.weekly?.usedPercent ?? 0);
|
|
6
|
+
return { healthy: usedPercent < 100, usedPercent };
|
|
7
|
+
}
|
|
8
|
+
export function workerCandidates(adapters, registered, snapshot) {
|
|
9
|
+
return adapters.filter(adapter => adapter.name !== 'orchestrator').flatMap(adapter => {
|
|
10
|
+
const defaults = adapter.name === 'chatgpt' ? { provider: 'chatgpt', model: 'account-defaults' } : { provider: 'openai', model: 'gpt-5.6-luna' };
|
|
11
|
+
const workers = registered.filter(worker => worker.runtime.adapter === adapter.name);
|
|
12
|
+
return (workers.length > 0 ? workers : [undefined]).map(worker => {
|
|
13
|
+
const provider = worker?.runtime.provider ?? defaults.provider;
|
|
14
|
+
const resource = usageForProvider(snapshot, provider);
|
|
15
|
+
return {
|
|
16
|
+
workerId: worker?.id ?? adapter.id,
|
|
17
|
+
adapter: adapter.name,
|
|
18
|
+
capabilities: worker?.capabilities ?? [
|
|
19
|
+
...adapter.capabilities,
|
|
20
|
+
...(adapter.name === 'codex' ? ['local_repo', 'iterative_tools', 'long_running'] : ['web_session', 'attachments', 'interactive_ui', 'one_shot']),
|
|
21
|
+
],
|
|
22
|
+
runtime: worker?.runtime.adapter ?? adapter.name,
|
|
23
|
+
trustDomains: worker?.routing.trustDomains,
|
|
24
|
+
region: worker?.routing.regions?.[0],
|
|
25
|
+
labels: worker?.routing.labels,
|
|
26
|
+
availability: worker?.availability,
|
|
27
|
+
resourceHealthy: resource.healthy,
|
|
28
|
+
resourceProvider: provider,
|
|
29
|
+
resourceUsedPercent: resource.usedPercent,
|
|
30
|
+
routing: { provider, model: worker?.runtime.model ?? defaults.model, decidedBy: 'model-router', decidedAt: new Date().toISOString() },
|
|
31
|
+
};
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
function satisfies(candidate, requirements) {
|
|
36
|
+
if (candidate.availability && candidate.availability !== 'available')
|
|
37
|
+
return false;
|
|
38
|
+
if (candidate.resourceHealthy === false)
|
|
39
|
+
return false;
|
|
40
|
+
if (!requirements.capabilities.every(capability => candidate.capabilities.includes(capability)))
|
|
41
|
+
return false;
|
|
42
|
+
if (requirements.runtime && candidate.runtime !== requirements.runtime)
|
|
43
|
+
return false;
|
|
44
|
+
if (requirements.taskKind && candidate.taskKind !== requirements.taskKind)
|
|
45
|
+
return false;
|
|
46
|
+
if (requirements.trustDomain && !candidate.trustDomains?.includes(requirements.trustDomain))
|
|
47
|
+
return false;
|
|
48
|
+
if (requirements.region && candidate.region !== requirements.region)
|
|
49
|
+
return false;
|
|
50
|
+
if (requirements.labels && Object.entries(requirements.labels).some(([key, value]) => candidate.labels?.[key] !== value))
|
|
51
|
+
return false;
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
export function selectWorker(candidates, requirements) {
|
|
55
|
+
const eligible = candidates.filter(candidate => satisfies(candidate, requirements));
|
|
56
|
+
if (eligible.length === 0)
|
|
57
|
+
throw new Error('No eligible worker satisfies the declared requirements');
|
|
58
|
+
const ranked = eligible.map((candidate, index) => ({ candidate, index })).sort((a, b) => {
|
|
59
|
+
const pressure = (item) => item.resourceUsedPercent === undefined ? 1 : item.resourceUsedPercent >= 90 ? 2 : 0;
|
|
60
|
+
return pressure(a.candidate) - pressure(b.candidate) || a.index - b.index;
|
|
61
|
+
});
|
|
62
|
+
const selected = ranked[0].candidate;
|
|
63
|
+
return {
|
|
64
|
+
candidate: selected,
|
|
65
|
+
selection: {
|
|
66
|
+
workerId: selected.workerId,
|
|
67
|
+
adapter: selected.adapter,
|
|
68
|
+
capabilities: [...selected.capabilities],
|
|
69
|
+
selectedBy: 'worker-router:requirements',
|
|
70
|
+
selectedAt: new Date().toISOString(),
|
|
71
|
+
resourceHealthy: selected.resourceHealthy,
|
|
72
|
+
resourceProvider: selected.resourceProvider,
|
|
73
|
+
resourceUsedPercent: selected.resourceUsedPercent,
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agents-relay",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.42",
|
|
4
4
|
"description": "Durable async agent jobs coordinated through GitHub pull requests",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
"scripts": {
|
|
11
11
|
"build": "tsc -p tsconfig.json && chmod +x dist/cli.js dist/relayd.js",
|
|
12
12
|
"test": "npm run build && node --test test/*.test.js",
|
|
13
|
+
"test:chatgpt-temp-quality": "AGENTS_RELAY_RUN_CHATGPT_TEMP_QUALITY=1 AGENTS_RELAY_CHATGPT_BROWSER_WORKER_AGENT=/Users/chengli/Workspace/neo/skills/chatgpt-browser-worker/agents/browser-worker.agent.md sh -c \"npm run build && node test/quality/run-temporary-chat.mjs\"",
|
|
13
14
|
"dev": "tsx src/cli.ts"
|
|
14
15
|
},
|
|
15
16
|
"devDependencies": {
|
|
@@ -13,12 +13,13 @@ Inspect the durable objective and task tree, then decide whether the objective i
|
|
|
13
13
|
- Prefer small independently verifiable tasks.
|
|
14
14
|
- Preserve parent/subtask causality and stable IDs.
|
|
15
15
|
- A child task describes what an agent must accomplish. Never emit a shell command as a worker task.
|
|
16
|
-
- Every child task must
|
|
16
|
+
- Every child task must declare `workerRequirements` (capabilities plus any runtime, task-kind, trust-domain, region, or label constraints). The worker-router resolves those requirements against the fresh usage snapshot and available workers before model-router chooses provider/model details.
|
|
17
|
+
- Do not choose `codex`/`chatgpt` or provider/model heuristically. When a selected worker is supplied by the caller, consume that selection; otherwise leave worker selection to the worker-router.
|
|
17
18
|
- Every child task must declare exactly one output: `task_pr` or `file`.
|
|
18
|
-
-
|
|
19
|
+
- Do not emit provider/model routing metadata; worker-router and model-router add it after planning.
|
|
19
20
|
- Every child task must include `storySize` according to the shared Task / Job Contract. Use `M` when uncertain.
|
|
20
21
|
- Return no prose outside the JSON result.
|
|
21
22
|
|
|
22
23
|
## Output
|
|
23
24
|
|
|
24
|
-
Return exactly one JSON object with `objective_status`, `assessment`, and `next_tasks`. Each next task contains id, input,
|
|
25
|
+
Return exactly one JSON object with `objective_status`, `assessment`, and `next_tasks`. Each next task contains id, input, `workerRequirements`, output, and storySize. Adapter/routing fields are selected by the runtime after worker-router runs; do not invent them. Each task may also contain priority, projectName, agentName, dependencies, capabilities, maxAttempts, and timeoutMs. When the objective is satisfied, next_tasks must be empty.
|
|
@@ -5,7 +5,18 @@ This contract is the shared protocol for every component that creates, schedules
|
|
|
5
5
|
## Task producers
|
|
6
6
|
|
|
7
7
|
Any task producer — orchestrator, planner, or nested task-producing agent — must follow this contract.
|
|
8
|
-
Task producers choose semantic task properties such as objective, dependencies,
|
|
8
|
+
Task producers choose semantic task properties such as objective, dependencies, worker requirements, priority, and `storySize`. They do not normally choose an adapter, provider/model, or raw timeout milliseconds.
|
|
9
|
+
|
|
10
|
+
## Worker routing order
|
|
11
|
+
|
|
12
|
+
Every new model-backed executable task follows this order:
|
|
13
|
+
|
|
14
|
+
1. The planner declares `workerRequirements` (capabilities and optional runtime, task-kind, trust-domain, region, and label constraints).
|
|
15
|
+
2. `worker-router` resolves those requirements against available workers and a fresh on-demand usage snapshot from the shared usage registry. It applies capability/availability hard gates before considering resource evidence.
|
|
16
|
+
3. `model-router` chooses provider/model/reasoning details for the selected worker.
|
|
17
|
+
4. Relay persists the selected worker and model routing on the durable task before launch.
|
|
18
|
+
|
|
19
|
+
Planner output must not hard-code `codex`/`chatgpt`, provider preference, or usage probes. The worker-router and model-router are separate responsibilities; usage readers remain owned by `src/usage.ts` and are not duplicated in either router. Legacy task records that already contain adapter/routing metadata remain readable, but newly produced planner tasks must carry worker requirements.
|
|
9
20
|
|
|
10
21
|
## Story size
|
|
11
22
|
|
|
@@ -27,6 +38,8 @@ Relay owns process lifecycle. Workers own semantic task lifecycle. A worker must
|
|
|
27
38
|
|
|
28
39
|
A normal task never merges a PR. After all non-superseded work gates pass, Relay creates a SHA-bound final review task. The reviewer emits exactly one of `review.approved`, `review.changes_required`, or `review.blocked`. When `autoMerge` is enabled (default), Relay merges only after `review.approved`, pinned to the exact reviewed head SHA. A new task or new PR head makes earlier final-review evidence stale and requires a fresh review.
|
|
29
40
|
|
|
41
|
+
Final-review routing follows the same worker-selection boundary as executable work. Capability, availability, explicit reviewer constraints, and resource health are hard gates. Among eligible workers, Relay prefers a reviewer with a different worker identity from the implementation tasks; when that is unavailable it prefers a different provider/model, then falls back to the first eligible worker. The selected worker, `selectedBy` reason, and `reviewerRouting` event data are durable evidence. No provider is a universal reviewer default.
|
|
42
|
+
|
|
30
43
|
## Fixed and autonomous jobs
|
|
31
44
|
|
|
32
45
|
`fixed` jobs execute the orchestrator-provided task set and then proceed to final review. `autonomous` jobs may ask the planner for additional task batches. Planner-produced tasks must obey this same contract, including explicit `storySize`.
|