agents-relay 1.0.42 → 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 +7 -0
- package/dist/cli.js +53 -4
- package/dist/events.js +6 -0
- package/dist/reconciler.js +12 -8
- package/dist/scheduled-jobs.js +195 -0
- package/dist/scheduler.js +173 -7
- package/package.json +1 -1
- package/skills/agents-relay/contracts/task-job-contract.md +2 -0
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),
|
|
@@ -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/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/reconciler.js
CHANGED
|
@@ -210,7 +210,7 @@ export class Reconciler {
|
|
|
210
210
|
await this.store.saveJob(job);
|
|
211
211
|
}
|
|
212
212
|
async ensureFinalReview(job, prState, headSha) {
|
|
213
|
-
if (job.reviewEnabled === false || prState !== 'OPEN' || job.state !== 'COMPLETED' || !headSha)
|
|
213
|
+
if (job.schedule || job.reviewEnabled === false || prState !== 'OPEN' || job.state !== 'COMPLETED' || !headSha)
|
|
214
214
|
return;
|
|
215
215
|
if (!this.preReviewGatesPass(job))
|
|
216
216
|
return;
|
|
@@ -385,31 +385,35 @@ export class Reconciler {
|
|
|
385
385
|
return job;
|
|
386
386
|
if (job.initialPlanning?.status === 'pending')
|
|
387
387
|
return job;
|
|
388
|
-
const
|
|
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));
|
|
389
392
|
if (planners.some(task => ['QUEUED', 'READY', 'RUNNING', 'WAITING'].includes(task.state)))
|
|
390
393
|
return job;
|
|
391
|
-
if (
|
|
394
|
+
if (scopedTasks.some(task => task.state === 'FAILED' || task.state === 'BLOCKED'))
|
|
392
395
|
return job;
|
|
393
|
-
if (
|
|
396
|
+
if (scopedTasks.length > 0 && !planners.some(task => task.state === 'SUCCEEDED'))
|
|
394
397
|
return job;
|
|
395
|
-
if (
|
|
398
|
+
if (scopedTasks.some(task => task.kind !== 'planner' && !['SUCCEEDED', 'CANCELLED'].includes(task.state)))
|
|
396
399
|
return job;
|
|
397
400
|
if (planners.length > 0 && planners.at(-1)?.plannerResult?.objective_status === 'in_progress' && !job.tasks.some(task => task.planningDecisionId === planners.at(-1)?.id))
|
|
398
401
|
return job;
|
|
399
402
|
const latest = planners.at(-1);
|
|
400
403
|
if (latest?.plannerResult?.objective_status === 'satisfied')
|
|
401
404
|
return job;
|
|
402
|
-
if (latest && job.review && job.review.outcome !== 'approved')
|
|
405
|
+
if (latest && !job.schedule && job.review && job.review.outcome !== 'approved')
|
|
403
406
|
return job;
|
|
407
|
+
const previousPlanner = allPlanners.at(-1);
|
|
404
408
|
const used = new Set(job.tasks.map(task => task.id));
|
|
405
|
-
let number =
|
|
409
|
+
let number = allPlanners.length + 1;
|
|
406
410
|
let id = `planner-${number}`;
|
|
407
411
|
while (used.has(id)) {
|
|
408
412
|
number += 1;
|
|
409
413
|
id = `planner-${number}`;
|
|
410
414
|
}
|
|
411
415
|
const now = new Date().toISOString();
|
|
412
|
-
const task = { jobId: job.id, id, kind: 'planner', storySize: 'M', agentName: 'planner', parentTaskId:
|
|
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 };
|
|
413
417
|
await this.store.appendTask(task);
|
|
414
418
|
await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'planner.created', 'queued', `Planner task ${task.id} created`, 'orchestrator'));
|
|
415
419
|
return this.store.load(job.id);
|
|
@@ -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
|
-
|
|
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
|
|
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 =
|
|
30
|
-
const blocked =
|
|
31
|
-
const waiting =
|
|
32
|
-
const failed =
|
|
33
|
-
const 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/package.json
CHANGED
|
@@ -43,3 +43,5 @@ Final-review routing follows the same worker-selection boundary as executable wo
|
|
|
43
43
|
## Fixed and autonomous jobs
|
|
44
44
|
|
|
45
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.
|