agents-relay 1.0.41 → 1.0.43

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -48,6 +48,13 @@ retries and daemon restarts do not create duplicate work.
48
48
  Task producers must follow `skills/agents-relay/contracts/task-job-contract.md`; that shared skill contract defines story size, lifecycle events, review, and auto-merge semantics.
49
49
  All task producers use the same story-size contract: `S`≈5 minutes, `M`≈15 minutes, and `L`≈45 minutes of normal-reasoning execution, with `M` as the default. `high` reasoning doubles that overall window. ChatGPT Browser Worker start-ack timeout is derived as one third of the effective overall timeout, clamped between 1 and 5 minutes. Orchestrators and planners should choose `storySize`, not raw timeout milliseconds; `--timeout` remains only as an explicit compatibility/debug override.
50
50
 
51
+ Autonomous jobs can also be scheduled without a Relay database. The job marker
52
+ stores a five-field cron expression, IANA timezone, next run, and missed-run /
53
+ concurrency policy; GitHub issue labels provide the indexed discovery surface.
54
+ A repository heartbeat invokes `tick` at 10-minute resolution. See
55
+ [Scheduled autonomous jobs](docs/scheduled-autonomous-jobs.md) for the exact
56
+ operational contract.
57
+
51
58
  job create first resolves an existing open PR with the same head/base, then creates it only when needed. Re-running it is idempotent: it reuses the PR and maintains exactly one trusted agents-relay:job:v1 marker.
52
59
  Use `--start` to create/reuse the job and submit its initial task in one invocation. Start mode requires explicit `--adapter`, `--model`, `--output`, and non-empty `--input`; the task ID defaults to `initial`. Re-running with the same task definition reuses that task and never duplicates it. Omitting `--start` preserves create-only behavior, leaving an `OPEN` job with zero tasks until a caller submits work.
53
60
 
package/dist/cli.js CHANGED
@@ -15,10 +15,11 @@ import { serveDashboard } from './dashboard.js';
15
15
  import { codexAndZaiUsageRegistry } from './usage.js';
16
16
  import { discoverAgents } from './registry.js';
17
17
  import { AgentObjectivePlanner, CommandObjectivePlanner, orchestratorPlanningDecision } from './planner.js';
18
- import { extendRetryBudget } from './scheduler.js';
18
+ import { extendRetryBudget, parseJobSchedule } from './scheduler.js';
19
+ import { dueBuckets, processDueScheduledJobs, syncScheduleLabels } from './scheduled-jobs.js';
19
20
  import { resolveExecutionContext } from './execution-context.js';
20
21
  import { defaultWorkspaceRoot, resolveManagedProjectPath } from './workspace.js';
21
- const usage = 'agents-relay <job|task|context|init|submit|record|agent-register|agent-discover|status|reconcile|retry|cancel|serve> [options]';
22
+ const usage = 'agents-relay <job|task|context|init|submit|record|agent-register|agent-discover|status|reconcile|tick|retry|cancel|serve> [options]';
22
23
  const help = {
23
24
  root: `Agents Relay coordinates durable asynchronous agent jobs through GitHub pull requests.
24
25
 
@@ -36,6 +37,7 @@ Commands:
36
37
  agent-discover Find registered agents matching hard filters
37
38
  status Show the durable job state
38
39
  reconcile Run reconciliation once
40
+ tick Run the indexed autonomous scheduler heartbeat
39
41
  retry|cancel Change a task state
40
42
  serve Run the dashboard and reconciliation service
41
43
 
@@ -58,6 +60,11 @@ Common options:
58
60
  --title TEXT Job title
59
61
  --objective TEXT Objective for autonomous mode
60
62
  --mode fixed|autonomous Execution mode (default: fixed)
63
+ --schedule CRON Autonomous schedule; minute resolution is 10 minutes
64
+ --schedule-timezone TZ IANA timezone for --schedule (default: UTC)
65
+ --schedule-next-run ISO Optional first run (must match the cron expression)
66
+ --missed-run-policy run|skip Run each missed slot or skip to the next future slot
67
+ --schedule-concurrency skip|queue Skip or retain the slot while work is active
61
68
  --start Create/reuse the job and submit its initial task
62
69
 
63
70
  Example:
@@ -140,6 +147,13 @@ Required: --file PATH for local demo mode, or --repo OWNER/REPO --pr NUMBER --id
140
147
 
141
148
  Example:
142
149
  npx agents-relay reconcile --repo OWNER/REPO --pr 12 --id job-1`,
150
+ tick: `Process autonomous jobs indexed into current/recent GitHub next-run buckets.
151
+
152
+ Required: --repo OWNER/REPO.
153
+ Common: --lookback-buckets N (default 3).
154
+
155
+ Example:
156
+ npx agents-relay tick --repo OWNER/REPO`,
143
157
  init: `Initialize a new job. GitHub mode requires --repo and --pr; local mode requires explicit --file.
144
158
 
145
159
  Common: --id ID, --title TEXT, --objective TEXT, --mode fixed|autonomous, --priority P0|P1|P2|P3.
@@ -257,6 +271,19 @@ export function chatGptTabClosePolicy(args) {
257
271
  }
258
272
  function requestedExecutionMode(args) { const value = arg(args, '--mode', arg(args, '--execution-mode')); return value ? executionMode(args) : undefined; }
259
273
  function continuation(args) { const kind = arg(args, '--continue-kind'); const target = arg(args, '--continue-target'); return kind && target ? { kind: kind, target, input: arg(args, '--continue-input') || undefined } : null; }
274
+ function scheduleFromArgs(args) {
275
+ const cron = arg(args, '--schedule');
276
+ if (!cron)
277
+ return undefined;
278
+ const nextRunAt = arg(args, '--schedule-next-run') || undefined;
279
+ return parseJobSchedule({
280
+ cron,
281
+ timezone: arg(args, '--schedule-timezone', 'UTC'),
282
+ nextRunAt,
283
+ missedRunPolicy: arg(args, '--missed-run-policy', 'skip'),
284
+ concurrencyPolicy: arg(args, '--schedule-concurrency', 'skip'),
285
+ });
286
+ }
260
287
  export async function ensureManagedGitHubJob(client, repositoryName, trustedAuthors, options) {
261
288
  if (!options.id)
262
289
  throw new Error('--id is required for managed GitHub jobs');
@@ -278,6 +305,8 @@ export async function ensureManagedGitHubJob(client, repositoryName, trustedAuth
278
305
  job.objective = options.objective;
279
306
  if (options.executionMode)
280
307
  job.executionMode = options.executionMode;
308
+ if (options.schedule)
309
+ job.schedule = options.schedule;
281
310
  if (options.autoMerge !== undefined)
282
311
  job.autoMerge = options.autoMerge;
283
312
  if (options.reviewEnabled !== undefined)
@@ -307,6 +336,8 @@ export async function ensureManagedGitHubJob(client, repositoryName, trustedAuth
307
336
  }
308
337
  else
309
338
  await store.saveJob(job);
339
+ if (job.schedule)
340
+ await syncScheduleLabels(client, repositoryName, job);
310
341
  return { job: await store.load(options.id), pr };
311
342
  }
312
343
  async function appendPlannedTask(store, job, task) {
@@ -494,6 +525,7 @@ async function runJobCommand(action, args) {
494
525
  objective: arg(args, '--objective') || undefined,
495
526
  priority: priority(args),
496
527
  executionMode: requestedExecutionMode(args),
528
+ schedule: scheduleFromArgs(args),
497
529
  autoMerge: !hasArg(args, '--no-auto-merge'),
498
530
  reviewEnabled: !hasArg(args, '--no-review'),
499
531
  continuation: continuation(args),
@@ -508,16 +540,16 @@ async function runJobCommand(action, args) {
508
540
  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));
509
541
  }
510
542
  export function runtimePlanner(args) { const plannerCommand = arg([...args], '--planner-command'); const modelRuntime = arg([...args], '--codex', 'codex'); return plannerCommand ? new CommandObjectivePlanner(plannerCommand) : new AgentObjectivePlanner(modelRuntime); }
511
- export function runtimeReviewer() { return { adapter: 'chatgpt', routing: { provider: 'chatgpt', model: 'account-defaults', decidedBy: 'fixed-chatgpt-reviewer', decidedAt: new Date().toISOString() } }; }
512
- 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()) }); }
543
+ export function runtimeReviewer() { return { requirements: { capabilities: ['model'] } }; }
544
+ 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()) }); }
513
545
  export async function createService(args) {
514
546
  const loaded = await storeFor(args);
515
547
  const upstream = eventBus(args);
516
548
  const bus = upstream ? new ServiceEventBus(upstream) : undefined;
517
- const reconciler = runtime(loaded.store, args, bus);
518
549
  const requestedUsageDays = Number(arg(args, '--usage-window-days', '1'));
519
550
  const usageWindowDays = Number.isInteger(requestedUsageDays) && requestedUsageDays > 0 ? requestedUsageDays : 1;
520
551
  const usageRegistry = codexAndZaiUsageRegistry(undefined, usageWindowDays);
552
+ const reconciler = runtime(loaded.store, args, bus, usageRegistry);
521
553
  const repo = arg(args, '--repo');
522
554
  let overviewProvider;
523
555
  if (repo) {
@@ -607,7 +639,7 @@ async function main() {
607
639
  }
608
640
  if (command === 'init') {
609
641
  const now = new Date().toISOString();
610
- const job = { id: arg(args, '--id', randomUUID()), title: arg(args, '--title', 'Agents Relay job'), objective: arg(args, '--objective') || undefined, priority: priority(args), executionMode: executionMode(args), autoMerge: !hasArg(args, '--no-auto-merge'), reviewEnabled: !hasArg(args, '--no-review'), prNumber: Number(arg(args, '--pr', '0')), repository: arg(args, '--repo'), state: 'OPEN', continuation: continuation(args), createdAt: now, updatedAt: now, tasks: [] };
642
+ const job = { id: arg(args, '--id', randomUUID()), title: arg(args, '--title', 'Agents Relay job'), objective: arg(args, '--objective') || undefined, priority: priority(args), executionMode: executionMode(args), schedule: scheduleFromArgs(args), autoMerge: !hasArg(args, '--no-auto-merge'), reviewEnabled: !hasArg(args, '--no-review'), prNumber: Number(arg(args, '--pr', '0')), repository: arg(args, '--repo'), state: 'OPEN', continuation: continuation(args), createdAt: now, updatedAt: now, tasks: [] };
611
643
  let store;
612
644
  let file = null;
613
645
  if (arg(args, '--repo') && arg(args, '--pr'))
@@ -623,9 +655,26 @@ async function main() {
623
655
  console.log(`Initialized ${job.id}`);
624
656
  return;
625
657
  }
658
+ const bus = eventBus(args);
659
+ if (command === 'tick') {
660
+ const repo = repository(args);
661
+ const auth = await githubAuthContext(args);
662
+ const nowValue = arg(args, '--now');
663
+ const now = nowValue ? new Date(nowValue) : new Date();
664
+ if (Number.isNaN(now.getTime()))
665
+ throw new Error('--now must be a valid ISO timestamp');
666
+ const results = await processDueScheduledJobs({
667
+ client: auth.client,
668
+ repository: repo,
669
+ trustedAuthors: auth.trustedAuthors,
670
+ buckets: dueBuckets(now, integerArg(args, '--lookback-buckets', 3)),
671
+ now,
672
+ });
673
+ console.log(JSON.stringify({ repository: repo, processed: results.length, results }, null, 2));
674
+ return;
675
+ }
626
676
  const loaded = await storeFor(args);
627
677
  const { store, job, localFile } = loaded;
628
- const bus = eventBus(args);
629
678
  if (command === 'agent-register') {
630
679
  const now = new Date().toISOString();
631
680
  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.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.snapshot());
367
+ return JSON.stringify(await readUsageSnapshot(usageRegistry));
367
368
  };
368
369
  const server = createServer(async (req, res) => {
369
370
  if (req.url === '/') {
package/dist/events.js CHANGED
@@ -50,5 +50,11 @@ export class NatsEventBus {
50
50
  })();
51
51
  return async () => { active = false; subscription.unsubscribe(); };
52
52
  }
53
+ async close() {
54
+ if (this.connection) {
55
+ await this.connection.close();
56
+ this.connection = null;
57
+ }
58
+ }
53
59
  }
54
60
  export function eventFor(jobId, taskId, parentTaskId, type, status, message, visibility = 'orchestrator', data, source) { return { version: 1, event_id: randomUUID(), job_id: jobId, task_id: taskId, parent_task_id: parentTaskId, type, status, timestamp: new Date().toISOString(), visibility, level: status === 'failed' || status === 'blocked' ? 'error' : 'info', message, data, source }; }
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 === undefined || !['codex', 'chatgpt'].includes(String(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
  };
@@ -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();
@@ -207,7 +210,7 @@ export class Reconciler {
207
210
  await this.store.saveJob(job);
208
211
  }
209
212
  async ensureFinalReview(job, prState, headSha) {
210
- if (job.reviewEnabled === false || prState !== 'OPEN' || job.state !== 'COMPLETED' || !headSha)
213
+ if (job.schedule || job.reviewEnabled === false || prState !== 'OPEN' || job.state !== 'COMPLETED' || !headSha)
211
214
  return;
212
215
  if (!this.preReviewGatesPass(job))
213
216
  return;
@@ -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 task = { jobId: job.id, id, kind: 'work', priority: job.priority, storySize: 'M', projectName: job.title, agentName: 'reviewer', parentTaskId: null, dependencies: job.tasks.filter(item => item.agentName !== 'reviewer' && !this.isSupersededTask(item)).map(item => item.id), capabilities: ['review'], adapter: this.options.reviewer?.adapter ?? 'codex', 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: this.options.reviewer?.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 };
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
  }
@@ -337,31 +385,35 @@ export class Reconciler {
337
385
  return job;
338
386
  if (job.initialPlanning?.status === 'pending')
339
387
  return job;
340
- const planners = job.tasks.filter(task => task.kind === 'planner').sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
388
+ const allPlanners = job.tasks.filter(task => task.kind === 'planner').sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
389
+ const slot = job.schedule?.currentRunSlot ? Date.parse(job.schedule.currentRunSlot) : Number.NaN;
390
+ const scopedTasks = Number.isFinite(slot) ? job.tasks.filter(task => Date.parse(task.createdAt) >= slot) : job.tasks;
391
+ const planners = scopedTasks.filter(task => task.kind === 'planner').sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
341
392
  if (planners.some(task => ['QUEUED', 'READY', 'RUNNING', 'WAITING'].includes(task.state)))
342
393
  return job;
343
- if (job.tasks.some(task => task.state === 'FAILED' || task.state === 'BLOCKED'))
394
+ if (scopedTasks.some(task => task.state === 'FAILED' || task.state === 'BLOCKED'))
344
395
  return job;
345
- if (job.tasks.length > 0 && !planners.some(task => task.state === 'SUCCEEDED'))
396
+ if (scopedTasks.length > 0 && !planners.some(task => task.state === 'SUCCEEDED'))
346
397
  return job;
347
- if (job.tasks.some(task => task.kind !== 'planner' && !['SUCCEEDED', 'CANCELLED'].includes(task.state)))
398
+ if (scopedTasks.some(task => task.kind !== 'planner' && !['SUCCEEDED', 'CANCELLED'].includes(task.state)))
348
399
  return job;
349
400
  if (planners.length > 0 && planners.at(-1)?.plannerResult?.objective_status === 'in_progress' && !job.tasks.some(task => task.planningDecisionId === planners.at(-1)?.id))
350
401
  return job;
351
402
  const latest = planners.at(-1);
352
403
  if (latest?.plannerResult?.objective_status === 'satisfied')
353
404
  return job;
354
- if (latest && job.review && job.review.outcome !== 'approved')
405
+ if (latest && !job.schedule && job.review && job.review.outcome !== 'approved')
355
406
  return job;
407
+ const previousPlanner = allPlanners.at(-1);
356
408
  const used = new Set(job.tasks.map(task => task.id));
357
- let number = planners.length + 1;
409
+ let number = allPlanners.length + 1;
358
410
  let id = `planner-${number}`;
359
411
  while (used.has(id)) {
360
412
  number += 1;
361
413
  id = `planner-${number}`;
362
414
  }
363
415
  const now = new Date().toISOString();
364
- const task = { jobId: job.id, id, kind: 'planner', storySize: 'M', agentName: 'planner', parentTaskId: latest?.id ?? null, dependencies: [], capabilities: ['planner'], adapter: 'orchestrator', input: plannerInput({ job, tasks: job.tasks, objective: job.objective ?? job.description ?? job.title, previousPlannerTaskId: latest?.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: planners.length + 1 };
416
+ 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 };
365
417
  await this.store.appendTask(task);
366
418
  await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'planner.created', 'queued', `Planner task ${task.id} created`, 'orchestrator'));
367
419
  return this.store.load(job.id);
@@ -591,7 +643,7 @@ export class Reconciler {
591
643
  }
592
644
  plannerChild(job, planner, spec) {
593
645
  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 ?? [], adapter: spec.adapter, input: spec.input, output: spec.output, routing: spec.routing, 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', spec.routing?.reasoning), createdAt: now, updatedAt: now, planningDecisionId: planner.id, planningSource: planner.planningSource ?? 'model-planner', planningRound: planner.planningRound ?? 1 };
646
+ 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
647
  }
596
648
  clearExecution(taskId, executionId) {
597
649
  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 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), eventBus: bus, reviewer: runtimeReviewer(), projectPathForJob: job => repositoryPaths.get(job.repository) ?? resolveManagedProjectPath(job.repository, explicitProjectPath, workspaceRoot) });
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
+ }
@@ -0,0 +1,195 @@
1
+ import { GitHubStore, loadManagedGitHubPullRequestGraphql } from './store.js';
2
+ import { bucketStart, nextScheduleRun, parseRunBucketLabel, runBucketLabel, scheduleBucket } from './scheduler.js';
3
+ export const AUTONOMOUS_LABEL = 'agents-relay:autonomous';
4
+ function searchPath(query, page) {
5
+ return `search/issues?q=${encodeURIComponent(query)}&sort=created&order=asc&per_page=100&page=${page}`;
6
+ }
7
+ export function dueBuckets(now, lookbackBuckets) {
8
+ if (!Number.isInteger(lookbackBuckets) || lookbackBuckets < 1 || lookbackBuckets > 36)
9
+ throw new Error('--lookback-buckets must be an integer from 1 to 36');
10
+ const result = [];
11
+ for (let age = lookbackBuckets - 1; age >= 0; age -= 1) {
12
+ result.push(scheduleBucket(new Date(bucketStart(now).getTime() - age * 600_000)));
13
+ }
14
+ return result;
15
+ }
16
+ export function indexedDueQuery(repository, bucket) {
17
+ const label = runBucketLabel(bucket);
18
+ return `repo:${repository} is:open is:pr label:"${AUTONOMOUS_LABEL}" label:"${label}"`;
19
+ }
20
+ async function issueLabels(client, repository, prNumber) {
21
+ const labels = [];
22
+ let page = 1;
23
+ for (;;) {
24
+ const response = await client.request(`repos/${repository}/issues/${prNumber}/labels?per_page=100&page=${page}`);
25
+ const items = Array.isArray(response) ? response : response.items;
26
+ if (!Array.isArray(items))
27
+ throw new Error(`GitHub label response for PR #${prNumber} was invalid`);
28
+ for (const item of items) {
29
+ const name = item.name;
30
+ if (typeof name === 'string')
31
+ labels.push(name);
32
+ }
33
+ if (items.length < 100)
34
+ return labels;
35
+ page += 1;
36
+ }
37
+ }
38
+ async function addLabel(client, repository, prNumber, label) {
39
+ await client.request(`repos/${repository}/issues/${prNumber}/labels`, {
40
+ method: 'POST',
41
+ body: JSON.stringify({ labels: [label] }),
42
+ });
43
+ }
44
+ async function removeLabel(client, repository, prNumber, label) {
45
+ await client.request(`repos/${repository}/issues/${prNumber}/labels/${encodeURIComponent(label)}`, { method: 'DELETE' });
46
+ }
47
+ async function ensureRepositoryLabel(client, repository, label, color) {
48
+ try {
49
+ await client.request(`repos/${repository}/labels/${encodeURIComponent(label)}`);
50
+ }
51
+ catch (error) {
52
+ const message = error instanceof Error ? error.message : String(error);
53
+ if (!/404|Not Found/i.test(message))
54
+ throw error;
55
+ await client.request(`repos/${repository}/labels`, {
56
+ method: 'POST',
57
+ body: JSON.stringify({ name: label, color }),
58
+ });
59
+ }
60
+ }
61
+ export async function syncScheduleLabels(client, repository, job) {
62
+ const current = await issueLabels(client, repository, job.prNumber);
63
+ const target = job.schedule ? runBucketLabel(scheduleBucket(new Date(job.schedule.nextRunAt))) : null;
64
+ const expected = new Set(job.schedule ? [AUTONOMOUS_LABEL, target] : []);
65
+ if (job.schedule || current.includes(AUTONOMOUS_LABEL))
66
+ await ensureRepositoryLabel(client, repository, AUTONOMOUS_LABEL, '0E8A16');
67
+ if (target !== null)
68
+ await ensureRepositoryLabel(client, repository, target, 'D4C5F9');
69
+ for (const label of current) {
70
+ const isSchedulerLabel = label === AUTONOMOUS_LABEL || parseRunBucketLabel(label) !== null || /^agents-relay:next-run:\d{8}T\d{4}Z$/.test(label);
71
+ if (isSchedulerLabel && !expected.has(label))
72
+ await removeLabel(client, repository, job.prNumber, label);
73
+ }
74
+ if (job.schedule) {
75
+ if (!current.includes(AUTONOMOUS_LABEL))
76
+ await addLabel(client, repository, job.prNumber, AUTONOMOUS_LABEL);
77
+ if (target !== null && !current.includes(target))
78
+ await addLabel(client, repository, job.prNumber, target);
79
+ }
80
+ }
81
+ const SCHEDULE_WAKE_MARKER = '<!-- agents-relay:schedule-wake:v1 -->';
82
+ async function pulseScheduleWake(client, repository, job) {
83
+ const body = `${SCHEDULE_WAKE_MARKER}
84
+ Scheduled autonomous wake for \`${job.id}\` at \`${job.schedule?.lastScheduledSlot ?? 'unknown'}\`.`;
85
+ let page = 1;
86
+ for (;;) {
87
+ const response = await client.request(`repos/${repository}/issues/${job.prNumber}/comments?per_page=100&page=${page}`);
88
+ const items = Array.isArray(response) ? response : [];
89
+ const existing = items.find(item => typeof item.body === 'string' && String(item.body).includes(SCHEDULE_WAKE_MARKER));
90
+ if (existing && Number.isInteger(Number(existing.id))) {
91
+ await client.request(`repos/${repository}/issues/comments/${Number(existing.id)}`, { method: 'PATCH', body: JSON.stringify({ body }) });
92
+ return;
93
+ }
94
+ if (items.length < 100)
95
+ break;
96
+ page += 1;
97
+ }
98
+ await client.request(`repos/${repository}/issues/${job.prNumber}/comments`, { method: 'POST', body: JSON.stringify({ body }) });
99
+ }
100
+ export async function listIndexedScheduledJobs(client, repository, trustedAuthors, buckets) {
101
+ const found = new Map();
102
+ const seenPrs = new Set();
103
+ for (const bucket of buckets) {
104
+ const query = indexedDueQuery(repository, bucket);
105
+ for (let page = 1; page <= 10; page += 1) {
106
+ const response = await client.request(searchPath(query, page));
107
+ const items = Array.isArray(response?.items) ? response.items : [];
108
+ for (const item of items) {
109
+ const prNumber = Number(item.number);
110
+ if (!Number.isInteger(prNumber) || prNumber <= 0 || seenPrs.has(prNumber))
111
+ continue;
112
+ seenPrs.add(prNumber);
113
+ const overviews = await loadManagedGitHubPullRequestGraphql(client, repository, prNumber, trustedAuthors).catch((error) => {
114
+ if (error instanceof Error && error.message.includes('has no durable'))
115
+ return [];
116
+ throw error;
117
+ });
118
+ const job = overviews.find(overview => overview.githubState === 'OPEN' && overview.job.schedule !== undefined)?.job;
119
+ if (!job?.schedule)
120
+ continue;
121
+ const slotBucket = scheduleBucket(new Date(job.schedule.nextRunAt));
122
+ if (!buckets.includes(slotBucket))
123
+ continue;
124
+ found.set(`${prNumber}:${job.id}`, { prNumber, jobId: job.id, bucket: slotBucket });
125
+ }
126
+ if (items.length < 100)
127
+ break;
128
+ }
129
+ }
130
+ return [...found.values()];
131
+ }
132
+ async function refresh(client, repository, trustedAuthors, ref) {
133
+ const store = new GitHubStore(client, repository, ref.prNumber, trustedAuthors);
134
+ return await store.load(ref.jobId);
135
+ }
136
+ export async function runScheduledBucket(client, repository, trustedAuthors, ref, engine, now = new Date()) {
137
+ let job = await refresh(client, repository, trustedAuthors, ref);
138
+ const schedule = job.schedule;
139
+ if (!schedule)
140
+ throw new Error(`Scheduled job ${job.id} has no schedule metadata`);
141
+ const slot = new Date(schedule.nextRunAt);
142
+ if (scheduleBucket(slot) !== ref.bucket || slot.getTime() > now.getTime()) {
143
+ await syncScheduleLabels(client, repository, job);
144
+ return { job, status: 'skipped-idempotent' };
145
+ }
146
+ if (schedule.lastScheduledSlot && new Date(schedule.lastScheduledSlot).getTime() >= slot.getTime()) {
147
+ await syncScheduleLabels(client, repository, job);
148
+ return { job, status: 'skipped-idempotent' };
149
+ }
150
+ const active = job.state === 'RUNNING' || job.tasks.some(task => ['READY', 'RUNNING'].includes(task.state));
151
+ if (active && schedule.concurrencyPolicy === 'skip') {
152
+ schedule.lastScheduledSlot = slot.toISOString();
153
+ schedule.nextRunAt = nextScheduleRun(schedule, now).toISOString();
154
+ job.updatedAt = now.toISOString();
155
+ await new GitHubStore(client, repository, ref.prNumber, trustedAuthors).saveJob(job);
156
+ await syncScheduleLabels(client, repository, job);
157
+ return { job, status: 'skipped-active' };
158
+ }
159
+ if (active && schedule.concurrencyPolicy === 'queue') {
160
+ await syncScheduleLabels(client, repository, job);
161
+ return { job, status: 'skipped-active' };
162
+ }
163
+ const missedRunPolicy = schedule.missedRunPolicy;
164
+ schedule.lastScheduledSlot = slot.toISOString();
165
+ schedule.currentRunSlot = slot.toISOString();
166
+ schedule.nextRunAt = nextScheduleRun(schedule, missedRunPolicy === 'run' ? slot : now).toISOString();
167
+ job.updatedAt = now.toISOString();
168
+ const schedulerStore = new GitHubStore(client, repository, ref.prNumber, trustedAuthors);
169
+ job.state = 'RUNNING';
170
+ await schedulerStore.saveJob(job);
171
+ job = await schedulerStore.load(job.id);
172
+ await syncScheduleLabels(client, repository, job);
173
+ if (engine) {
174
+ await engine.reconcile(job.id);
175
+ await engine.idle();
176
+ const finished = await schedulerStore.load(job.id);
177
+ if (!finished.schedule)
178
+ throw new Error(`Scheduled job ${job.id} lost its schedule metadata`);
179
+ finished.schedule.lastRunAt = now.toISOString();
180
+ finished.updatedAt = now.toISOString();
181
+ await schedulerStore.saveJob(finished);
182
+ return { job: finished, status: 'completed' };
183
+ }
184
+ await pulseScheduleWake(client, repository, job);
185
+ return { job, status: 'dispatched' };
186
+ }
187
+ export async function processDueScheduledJobs(options) {
188
+ const due = await listIndexedScheduledJobs(options.client, options.repository, options.trustedAuthors, options.buckets);
189
+ const results = [];
190
+ for (const ref of due) {
191
+ const engine = options.engine ? options.engine(await refresh(options.client, options.repository, options.trustedAuthors, ref)) : undefined;
192
+ results.push(await runScheduledBucket(options.client, options.repository, options.trustedAuthors, ref, engine, options.now));
193
+ }
194
+ return results;
195
+ }
package/dist/scheduler.js CHANGED
@@ -5,7 +5,15 @@ export function detectCycles(tasks) { const map = new Map(tasks.map(t => [t.id,
5
5
  return; visiting.add(id); for (const dep of map.get(id)?.dependencies ?? [])
6
6
  visit(dep); visiting.delete(id); done.add(id); }; for (const task of tasks)
7
7
  visit(task.id); }
8
+ function scheduledCycleTasks(job) {
9
+ const slot = job.schedule?.currentRunSlot ? Date.parse(job.schedule.currentRunSlot) : Number.NaN;
10
+ if (!Number.isFinite(slot))
11
+ return job.tasks;
12
+ return job.tasks.filter(task => Date.parse(task.createdAt) >= slot);
13
+ }
8
14
  export function completionGateSatisfied(job) {
15
+ if (job.schedule)
16
+ return false;
9
17
  if (job.executionMode !== 'autonomous')
10
18
  return true;
11
19
  const planners = job.tasks.filter(task => task.kind === 'planner');
@@ -13,7 +21,8 @@ export function completionGateSatisfied(job) {
13
21
  }
14
22
  export function schedule(job) {
15
23
  detectCycles(job.tasks);
16
- if (job.tasks.length === 0) {
24
+ const scopedTasks = scheduledCycleTasks(job);
25
+ if (scopedTasks.length === 0) {
17
26
  if (job.executionMode === 'autonomous' && job.state !== 'WAITING') {
18
27
  assertJobTransition(job.state, 'WAITING');
19
28
  job.state = 'WAITING';
@@ -22,16 +31,27 @@ export function schedule(job) {
22
31
  return job;
23
32
  }
24
33
  const map = new Map(job.tasks.map(t => [t.id, t]));
25
- for (const task of job.tasks) {
34
+ for (const task of scopedTasks) {
26
35
  if (['QUEUED', 'WAITING'].includes(task.state) && dependencyReady(task, map))
27
36
  transitionTask(task, 'READY');
28
37
  }
29
- const active = job.tasks.some(t => ['READY', 'RUNNING'].includes(t.state));
30
- const blocked = job.tasks.some(t => t.state === 'BLOCKED');
31
- const waiting = job.tasks.some(t => ['QUEUED', 'WAITING'].includes(t.state));
32
- const failed = job.tasks.some(t => t.state === 'FAILED');
33
- const cancelled = job.tasks.every(t => t.state === 'CANCELLED');
38
+ const active = scopedTasks.some(t => ['READY', 'RUNNING'].includes(t.state));
39
+ const blocked = scopedTasks.some(t => t.state === 'BLOCKED');
40
+ const waiting = scopedTasks.some(t => ['QUEUED', 'WAITING'].includes(t.state));
41
+ const failed = scopedTasks.some(t => t.state === 'FAILED');
42
+ const cancelled = scopedTasks.every(t => t.state === 'CANCELLED');
34
43
  const next = active ? 'RUNNING' : failed ? 'FAILED' : blocked ? 'BLOCKED' : waiting ? 'WAITING' : cancelled ? 'CANCELLED' : completionGateSatisfied(job) ? 'COMPLETED' : 'WAITING';
44
+ if (job.schedule && next === 'WAITING') {
45
+ const planners = scopedTasks.filter(task => task.kind === 'planner');
46
+ const latest = planners.sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id)).at(-1);
47
+ if (latest?.state === 'SUCCEEDED' && latest.plannerResult?.objective_status === 'satisfied') {
48
+ const slot = job.schedule.currentRunSlot ? Date.parse(job.schedule.currentRunSlot) : Number.NaN;
49
+ const lastRun = job.schedule.lastRunAt ? Date.parse(job.schedule.lastRunAt) : Number.NaN;
50
+ if (Number.isFinite(slot) && (!Number.isFinite(lastRun) || lastRun < slot))
51
+ job.schedule.lastRunAt = new Date().toISOString();
52
+ job.schedule.currentRunSlot = null;
53
+ }
54
+ }
35
55
  if (job.state !== next) {
36
56
  assertJobTransition(job.state, next);
37
57
  job.state = next;
@@ -47,3 +67,149 @@ export function extendRetryBudget(task, windowSize = 3) {
47
67
  }
48
68
  export function transitionTask(task, state) { assertTaskTransition(task.state, state); task.state = state; if (['READY', 'RUNNING', 'SUCCEEDED'].includes(state))
49
69
  task.error = null; task.updatedAt = new Date().toISOString(); }
70
+ export const SCHEDULE_RESOLUTION_MS = 600_000;
71
+ export const RUN_BUCKET_LABEL_COUNT = 144;
72
+ const CRON_PART = /^(?:\*|(\d+)(?:-(\d+))?)(?:\/(\d+))?$/;
73
+ function cronField(raw, name, minimum, maximum) {
74
+ const values = new Set();
75
+ for (const part of raw.split(',')) {
76
+ const match = CRON_PART.exec(part);
77
+ if (!match)
78
+ throw new Error(`Invalid schedule ${name}: ${part}`);
79
+ const [whole, firstText, lastText, stepText] = match;
80
+ if (whole === undefined)
81
+ continue;
82
+ const step = stepText === undefined ? 1 : Number(stepText);
83
+ if (!Number.isInteger(step) || step < 1)
84
+ throw new Error(`Invalid schedule ${name} step: ${part}`);
85
+ let start = minimum;
86
+ let end = maximum;
87
+ if (firstText !== undefined) {
88
+ start = Number(firstText);
89
+ end = lastText === undefined ? (stepText === undefined ? start : maximum) : Number(lastText);
90
+ if (start < minimum || end > maximum || start > end)
91
+ throw new Error(`Invalid schedule ${name} range: ${part}`);
92
+ }
93
+ for (let value = start; value <= end; value += step) {
94
+ if (value > maximum)
95
+ break;
96
+ values.add(name === 'weekday' && value === 7 ? 0 : value);
97
+ }
98
+ }
99
+ if (values.size === 0)
100
+ throw new Error(`Invalid schedule ${name}: ${raw}`);
101
+ return values;
102
+ }
103
+ export function parseCron(cron) {
104
+ const fields = cron.trim().split(/\s+/);
105
+ if (fields.length !== 5)
106
+ throw new Error('Schedule cron must contain exactly five fields');
107
+ const parsed = {
108
+ minute: cronField(fields[0], 'minute', 0, 59),
109
+ hour: cronField(fields[1], 'hour', 0, 23),
110
+ dayOfMonth: cronField(fields[2], 'day-of-month', 1, 31),
111
+ month: cronField(fields[3], 'month', 1, 12),
112
+ weekday: cronField(fields[4], 'weekday', 0, 7),
113
+ };
114
+ for (const minute of parsed.minute)
115
+ if (minute % (SCHEDULE_RESOLUTION_MS / 60_000) !== 0)
116
+ throw new Error(`Schedule minute ${minute} is finer than the 10-minute scheduler resolution`);
117
+ return parsed;
118
+ }
119
+ function timezoneParts(time, timeZone) {
120
+ const parts = new Intl.DateTimeFormat('en-US', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', weekday: 'short', hourCycle: 'h23' }).formatToParts(time);
121
+ const value = (type) => parts.find(part => part.type === type)?.value ?? '';
122
+ const weekdays = new Map([['Sun', 0], ['Mon', 1], ['Tue', 2], ['Wed', 3], ['Thu', 4], ['Fri', 5], ['Sat', 6]]);
123
+ const weekday = weekdays.get(value('weekday'));
124
+ if (weekday === undefined)
125
+ throw new Error(`Unable to resolve timezone weekday ${timeZone}`);
126
+ return { year: Number(value('year')), month: Number(value('month')), day: Number(value('day')), hour: Number(value('hour')), minute: Number(value('minute')), weekday };
127
+ }
128
+ function cronMatches(fields, time, timeZone) {
129
+ const local = timezoneParts(time, timeZone);
130
+ if (!fields.minute.has(local.minute) || !fields.hour.has(local.hour) || !fields.month.has(local.month))
131
+ return false;
132
+ const domWildcard = fields.dayOfMonth.size === 31;
133
+ const dowWildcard = fields.weekday.size === 7;
134
+ const domMatch = fields.dayOfMonth.has(local.day);
135
+ const dowMatch = fields.weekday.has(local.weekday);
136
+ const dayMatch = domWildcard && dowWildcard ? true : domWildcard ? dowMatch : dowWildcard ? domMatch : domMatch || dowMatch;
137
+ return dayMatch;
138
+ }
139
+ export function nextCronRun(cron, timeZone, after = new Date()) {
140
+ const fields = parseCron(cron);
141
+ if (!isValidTimeZone(timeZone))
142
+ throw new Error(`Invalid schedule timezone: ${timeZone}`);
143
+ const candidate = new Date(Math.floor((after.getTime() + 60_000) / 60_000) * 60_000);
144
+ const limit = candidate.getTime() + 366 * 24 * 60 * 60_000;
145
+ while (candidate.getTime() < limit) {
146
+ if (cronMatches(fields, candidate, timeZone))
147
+ return new Date(candidate);
148
+ candidate.setUTCMinutes(candidate.getUTCMinutes() + 1);
149
+ }
150
+ throw new Error(`Schedule ${cron} has no next run within one year`);
151
+ }
152
+ export function isValidTimeZone(timeZone) {
153
+ try {
154
+ new Intl.DateTimeFormat('en-US', { timeZone });
155
+ return true;
156
+ }
157
+ catch {
158
+ return false;
159
+ }
160
+ }
161
+ export function parseJobSchedule(options) {
162
+ const fields = parseCron(options.cron);
163
+ if (!isValidTimeZone(options.timezone))
164
+ throw new Error(`Invalid schedule timezone: ${options.timezone}`);
165
+ const missedRunPolicy = options.missedRunPolicy ?? 'skip';
166
+ const concurrencyPolicy = options.concurrencyPolicy ?? 'skip';
167
+ if (!['run', 'skip'].includes(missedRunPolicy))
168
+ throw new Error('Invalid missed-run policy');
169
+ if (!['skip', 'queue'].includes(concurrencyPolicy))
170
+ throw new Error('Invalid schedule concurrency policy');
171
+ const next = options.nextRunAt ? new Date(options.nextRunAt) : nextCronRun(options.cron, options.timezone);
172
+ if (Number.isNaN(next.getTime()))
173
+ throw new Error('Invalid schedule next-run time');
174
+ const aligned = new Date(Math.floor(next.getTime() / 60_000) * 60_000);
175
+ if (!cronMatches(fields, aligned, options.timezone))
176
+ throw new Error('Schedule next-run time does not match the cron expression');
177
+ return {
178
+ cron: options.cron.trim(),
179
+ timezone: options.timezone,
180
+ nextRunAt: aligned.toISOString(),
181
+ missedRunPolicy,
182
+ concurrencyPolicy,
183
+ lastScheduledSlot: null,
184
+ currentRunSlot: null,
185
+ lastRunAt: null,
186
+ };
187
+ }
188
+ export function nextScheduleRun(schedule, after = new Date()) {
189
+ return nextCronRun(schedule.cron, schedule.timezone, after);
190
+ }
191
+ export function bucketStart(time = new Date()) {
192
+ return new Date(Math.floor(time.getTime() / SCHEDULE_RESOLUTION_MS) * SCHEDULE_RESOLUTION_MS);
193
+ }
194
+ export function scheduleBucket(time) {
195
+ const bucket = bucketStart(time);
196
+ return `${bucket.toISOString().slice(0, 4)}${bucket.toISOString().slice(5, 7)}${bucket.toISOString().slice(8, 10)}T${bucket.toISOString().slice(11, 13)}${bucket.toISOString().slice(14, 16)}Z`;
197
+ }
198
+ export function runBucketLabel(bucket) {
199
+ if (!/^\d{8}T\d{4}Z$/.test(bucket))
200
+ throw new Error(`Invalid schedule bucket: ${bucket}`);
201
+ const hour = Number(bucket.slice(9, 11));
202
+ const minute = Number(bucket.slice(11, 13));
203
+ const slot = hour * 6 + minute / 10;
204
+ return `agents-relay:next-run:${slot}`;
205
+ }
206
+ export function parseRunBucketLabel(label) {
207
+ const prefix = 'agents-relay:next-run:';
208
+ if (!label.startsWith(prefix))
209
+ return null;
210
+ const value = label.slice(prefix.length);
211
+ if (!/^\d{1,3}$/.test(value))
212
+ return null;
213
+ const slot = Number(value);
214
+ return Number.isInteger(slot) && slot >= 0 && slot < RUN_BUCKET_LABEL_COUNT ? value : null;
215
+ }
package/dist/store.js CHANGED
@@ -82,7 +82,7 @@ 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,
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
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
  }
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.41",
3
+ "version": "1.0.43",
4
4
  "description": "Durable async agent jobs coordinated through GitHub pull requests",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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 use adapter `codex` or `chatgpt`.
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
- - Include routing metadata for every model-backed child task.
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, adapter, output, routing, storySize, and may also contain priority, projectName, agentName, dependencies, capabilities, maxAttempts, and timeoutMs. When the objective is satisfied, next_tasks must be empty.
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, adapter/routing, priority, and `storySize`. They do not normally choose raw timeout milliseconds.
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,10 @@ 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`.
46
+
47
+ A scheduled autonomous job adds cron/timezone/next-run and missed-run/concurrency policy to the trusted job marker, plus the `agents-relay:autonomous` label and one bounded time-of-day `agents-relay:next-run:*` index label. The GitHub heartbeat discovers only indexed due candidates, validates the marker's exact `nextRunAt`, claims the slot idempotently, advances the durable next run, and pulses `agents-relay:schedule-wake`. GitHub webhook delivery then wakes local Relay for planner/worker execution. While the schedule is active, each satisfied run returns the job to `WAITING` instead of entering final-review/auto-merge; older-cycle failures are historical evidence and do not block a later slot.