agents-relay 1.0.168 → 1.0.169

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/dist/cli-help.js CHANGED
@@ -1,4 +1,4 @@
1
- export const usage = 'agents-relay <job|task|plan|blocker|context|init|record|agent-register|agent-discover|status|reconcile|tick|serve> [options]';
1
+ export const usage = 'agents-relay <job|task|plan|blocker|context|init|record|agent-register|agent-discover|status|reconcile|serve> [options]';
2
2
  const help = {
3
3
  root: `Agents Relay coordinates durable asynchronous agent jobs through GitHub pull requests.
4
4
 
@@ -17,7 +17,6 @@ Commands:
17
17
  agent-discover Find registered agents matching hard filters
18
18
  status Show live aggregate runtime status from agents-relayd
19
19
  reconcile Run reconciliation once
20
- tick Run the indexed autonomous scheduler heartbeat
21
20
  serve Run the dashboard and reconciliation service
22
21
 
23
22
  Run 'npx agents-relay <command> --help' for command options and examples.
@@ -184,13 +183,6 @@ Managed GitHub jobs are reconciled by agents-relayd. The CLI publishes a job.wak
184
183
 
185
184
  Example:
186
185
  npx agents-relay reconcile --repo OWNER/REPO --pr 12 --id job-1`,
187
- tick: `Process autonomous jobs indexed into current/recent GitHub next-run buckets.
188
-
189
- Required: --repo OWNER/REPO.
190
- Common: --lookback-buckets N (default 3).
191
-
192
- Example:
193
- npx agents-relay tick --repo OWNER/REPO`,
194
186
  init: `Initialize a new job. GitHub mode requires --repo and --pr; local mode requires explicit --file.
195
187
 
196
188
  Common: --id ID, --title TEXT, --objective TEXT, --mode fixed|autonomous, --priority P0|P1|P2|P3.
package/dist/cli.js CHANGED
@@ -18,7 +18,6 @@ import { decisionMode, LayaCliDecisionEngine } from './decision.js';
18
18
  import { discoverAgents } from './registry.js';
19
19
  import { AgentObjectivePlanner, CommandObjectivePlanner } from './planner.js';
20
20
  import { extendRetryBudget, transitionJobEvent, transitionTaskEvent } from './scheduler.js';
21
- import { dueBuckets, processDueScheduledJobs, syncScheduleLabels } from './scheduled-jobs.js';
22
21
  import { resolveExecutionContext } from './execution-context.js';
23
22
  import { defaultWorkspaceRoot, resolveManagedProjectPath, resolveRepositoryProjectPath } from './workspace.js';
24
23
  import { runBlockerCommand } from './cli-blocker.js';
@@ -35,8 +34,6 @@ export async function ensureManagedGitHubJob(client, repositoryName, trustedAuth
35
34
  const store = new GitHubStore(client, repositoryName, pr.number, trustedAuthors);
36
35
  try {
37
36
  const existing = await store.load(options.id);
38
- if (existing.schedule)
39
- await syncScheduleLabels(client, repositoryName, existing);
40
37
  return { job: existing, pr };
41
38
  }
42
39
  catch (error) {
@@ -66,8 +63,6 @@ export async function ensureManagedGitHubJob(client, repositoryName, trustedAuth
66
63
  if (options.schedule)
67
64
  job.schedule = options.schedule;
68
65
  await store.saveJob(job);
69
- if (job.schedule)
70
- await syncScheduleLabels(client, repositoryName, job);
71
66
  return { job: await store.load(options.id), pr };
72
67
  }
73
68
  async function workerTaskFromArgs(job, args, now) {
@@ -450,8 +445,6 @@ async function runJobCommand(action, args) {
450
445
  }
451
446
  job.updatedAt = new Date().toISOString();
452
447
  await store.saveJob(job);
453
- if (hasArg(args, '--schedule') && job.schedule)
454
- await syncScheduleLabels(auth.client, repo, job);
455
448
  console.log(JSON.stringify(await store.load(id), null, 2));
456
449
  }
457
450
  export function runtimePlanner(args) { const plannerCommand = arg([...args], '--planner-command'); const modelRuntime = arg([...args], '--codex', 'codex'); return plannerCommand ? new CommandObjectivePlanner(plannerCommand) : new AgentObjectivePlanner(modelRuntime); }
@@ -629,23 +622,6 @@ async function main() {
629
622
  console.log(JSON.stringify(await response.json(), null, 2));
630
623
  return;
631
624
  }
632
- if (command === 'tick') {
633
- const repo = repository(args);
634
- const auth = await githubAuthContext(args);
635
- const nowValue = arg(args, '--now');
636
- const now = nowValue ? new Date(nowValue) : new Date();
637
- if (Number.isNaN(now.getTime()))
638
- throw new Error('--now must be a valid ISO timestamp');
639
- const results = await processDueScheduledJobs({
640
- client: auth.client,
641
- repository: repo,
642
- trustedAuthors: auth.trustedAuthors,
643
- buckets: dueBuckets(now, integerArg(args, '--lookback-buckets', 3)),
644
- now,
645
- });
646
- console.log(JSON.stringify({ repository: repo, processed: results.length, results }, null, 2));
647
- return;
648
- }
649
625
  const loaded = await storeFor(args);
650
626
  const { store, job, localFile } = loaded;
651
627
  if (command === 'plan') {
@@ -1,134 +1,5 @@
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
- }
1
+ import { GitHubStore } from './store.js';
2
+ import { nextScheduleRun, scheduleBucket } from './scheduler.js';
132
3
  async function refresh(client, repository, trustedAuthors, ref) {
133
4
  const store = new GitHubStore(client, repository, ref.prNumber, trustedAuthors);
134
5
  return await store.load(ref.jobId);
@@ -140,11 +11,9 @@ export async function runScheduledBucket(client, repository, trustedAuthors, ref
140
11
  throw new Error(`Scheduled job ${job.id} has no schedule metadata`);
141
12
  const slot = new Date(schedule.nextRunAt);
142
13
  if (scheduleBucket(slot) !== ref.bucket || slot.getTime() > now.getTime()) {
143
- await syncScheduleLabels(client, repository, job);
144
14
  return { job, status: 'skipped-idempotent' };
145
15
  }
146
16
  if (schedule.lastScheduledSlot && new Date(schedule.lastScheduledSlot).getTime() >= slot.getTime()) {
147
- await syncScheduleLabels(client, repository, job);
148
17
  return { job, status: 'skipped-idempotent' };
149
18
  }
150
19
  if (job.state !== 'ACTIVE')
@@ -155,11 +24,9 @@ export async function runScheduledBucket(client, repository, trustedAuthors, ref
155
24
  schedule.nextRunAt = nextScheduleRun(schedule, now).toISOString();
156
25
  job.updatedAt = now.toISOString();
157
26
  await new GitHubStore(client, repository, ref.prNumber, trustedAuthors).saveJob(job);
158
- await syncScheduleLabels(client, repository, job);
159
27
  return { job, status: 'skipped-active' };
160
28
  }
161
29
  if (active && schedule.concurrencyPolicy === 'queue') {
162
- await syncScheduleLabels(client, repository, job);
163
30
  return { job, status: 'skipped-active' };
164
31
  }
165
32
  const missedRunPolicy = schedule.missedRunPolicy;
@@ -170,27 +37,15 @@ export async function runScheduledBucket(client, repository, trustedAuthors, ref
170
37
  const schedulerStore = new GitHubStore(client, repository, ref.prNumber, trustedAuthors);
171
38
  await schedulerStore.saveJob(job);
172
39
  job = await schedulerStore.load(job.id);
173
- await syncScheduleLabels(client, repository, job);
174
- if (engine) {
175
- await engine.reconcile(job.id);
176
- await engine.idle();
177
- const finished = await schedulerStore.load(job.id);
178
- if (!finished.schedule)
179
- throw new Error(`Scheduled job ${job.id} lost its schedule metadata`);
180
- finished.schedule.lastRunAt = now.toISOString();
181
- finished.updatedAt = now.toISOString();
182
- await schedulerStore.saveJob(finished);
183
- return { job: finished, status: 'completed' };
184
- }
185
- await pulseScheduleWake(client, repository, job);
186
- return { job, status: 'dispatched' };
187
- }
188
- export async function processDueScheduledJobs(options) {
189
- const due = await listIndexedScheduledJobs(options.client, options.repository, options.trustedAuthors, options.buckets);
190
- const results = [];
191
- for (const ref of due) {
192
- const engine = options.engine ? options.engine(await refresh(options.client, options.repository, options.trustedAuthors, ref)) : undefined;
193
- results.push(await runScheduledBucket(options.client, options.repository, options.trustedAuthors, ref, engine, options.now));
194
- }
195
- return results;
40
+ if (!engine)
41
+ throw new Error('Scheduled jobs require the local relayd scheduler engine');
42
+ await engine.reconcile(job.id);
43
+ await engine.idle();
44
+ const finished = await schedulerStore.load(job.id);
45
+ if (!finished.schedule)
46
+ throw new Error(`Scheduled job ${job.id} lost its schedule metadata`);
47
+ finished.schedule.lastRunAt = now.toISOString();
48
+ finished.updatedAt = now.toISOString();
49
+ await schedulerStore.saveJob(finished);
50
+ return { job: finished, status: 'completed' };
196
51
  }
package/dist/scheduler.js CHANGED
@@ -62,7 +62,6 @@ export async function transitionJobEvent(job, event, context = {}) {
62
62
  return decision;
63
63
  }
64
64
  export const SCHEDULE_RESOLUTION_MS = 600_000;
65
- export const RUN_BUCKET_LABEL_COUNT = 144;
66
65
  const CRON_PART = /^(?:\*|(\d+)(?:-(\d+))?)(?:\/(\d+))?$/;
67
66
  function cronField(raw, name, minimum, maximum) {
68
67
  const values = new Set();
@@ -189,21 +188,3 @@ export function scheduleBucket(time) {
189
188
  const bucket = bucketStart(time);
190
189
  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`;
191
190
  }
192
- export function runBucketLabel(bucket) {
193
- if (!/^\d{8}T\d{4}Z$/.test(bucket))
194
- throw new Error(`Invalid schedule bucket: ${bucket}`);
195
- const hour = Number(bucket.slice(9, 11));
196
- const minute = Number(bucket.slice(11, 13));
197
- const slot = hour * 6 + minute / 10;
198
- return `agents-relay:next-run:${slot}`;
199
- }
200
- export function parseRunBucketLabel(label) {
201
- const prefix = 'agents-relay:next-run:';
202
- if (!label.startsWith(prefix))
203
- return null;
204
- const value = label.slice(prefix.length);
205
- if (!/^\d{1,3}$/.test(value))
206
- return null;
207
- const slot = Number(value);
208
- return Number.isInteger(slot) && slot >= 0 && slot < RUN_BUCKET_LABEL_COUNT ? value : null;
209
- }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-relay",
3
- "version": "1.0.168",
3
+ "version": "1.0.169",
4
4
  "description": "Durable async agent jobs coordinated through GitHub pull requests",
5
5
  "type": "module",
6
6
  "bin": {
@@ -109,9 +109,9 @@ SHA-bound final reviewer. For example, `video-director + market + style ->
109
109
  review-chair -> unified revision plan` is a project pattern, not a hard-coded
110
110
  Relay reviewer list.
111
111
 
112
- 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 always-on `agents-relayd` process is the authoritative heartbeat for repositories it manages: it evaluates due schedule buckets on startup and every ten minutes and reconciles claimed work through the local worker pool. A repository may also install `.github/workflows/autonomous-scheduler.yml` as an optional cloud fallback; GitHub `schedule` delivery is best-effort and must not be relied on for precise cadence. Unscheduled autonomous Jobs and fixed Jobs do not use the scheduler heartbeat.
112
+ A scheduled autonomous job adds cron/timezone/next-run and missed-run/concurrency policy to the trusted job marker. The always-on `agents-relayd` process is the sole heartbeat for repositories it manages: on startup and every ten minutes it evaluates cached managed Jobs and reconciles due work through the local worker pool. There is no GitHub Actions scheduler fallback. Unscheduled autonomous Jobs and fixed Jobs do not use the scheduler heartbeat.
113
113
 
114
- The scheduler 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, the durable Job remains `ACTIVE`; between runs its derived runtime status is `WAITING`. Scheduled recurring Jobs do not enter one-shot final-review/auto-merge after each satisfied run; older-cycle failures are historical evidence and do not block a later slot.
114
+ The scheduler heartbeat filters cached managed Jobs, reloads each due Job to validate the marker's exact `nextRunAt`, claims the slot idempotently, advances the durable next run, and reconciles planner/worker execution locally. While the schedule is active, the durable Job remains `ACTIVE`; between runs its derived runtime status is `WAITING`. Scheduled recurring Jobs do not enter one-shot final-review/auto-merge after each satisfied run; older-cycle failures are historical evidence and do not block a later slot.
115
115
 
116
116
 
117
117
  ## Task evidence and head identity