agents-relay 1.0.54 → 1.0.56
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 +9 -2
- package/dist/cli.js +66 -17
- package/dist/dashboard.js +9 -9
- package/dist/pool.js +4 -4
- package/dist/reconciler.js +26 -26
- package/dist/relayd.js +2 -1
- package/dist/runtime-status.js +52 -0
- package/dist/scheduled-jobs.js +3 -2
- package/dist/scheduler.js +9 -22
- package/dist/store.js +4 -0
- package/dist/types.js +3 -3
- package/package.json +1 -1
- package/skills/agents-relay/contracts/task-job-contract.md +25 -2
package/README.md
CHANGED
|
@@ -23,14 +23,21 @@ npx agents-relay reconcile --repo OWNER/REPO --pr 12 --id job-1
|
|
|
23
23
|
npx agents-relay job show --repo OWNER/REPO --pr 12 --id job-1
|
|
24
24
|
~~~
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
Task readiness is explicit: `DRAFT` means the durable task exists but is not executable yet; `QUEUED` means Relay may execute it now; `RUNNING` means worker execution has started. Tasks created with dependencies start `DRAFT` and become `QUEUED` when those dependencies succeed. A dependency-free task created with `--draft` stays held until `task update --queue`. Once a task has run, external waiting is represented as `BLOCKED`, never by returning to `DRAFT`.
|
|
27
|
+
|
|
28
|
+
Mutable draft/queued/retryable task fields can be changed in place without replacing the durable task identity. `task update` rejects `RUNNING`, `SUCCEEDED`, and `CANCELLED` tasks so active execution and terminal history cannot be rewritten. For example:
|
|
27
29
|
|
|
28
30
|
~~~sh
|
|
29
31
|
npx agents-relay task update --repo OWNER/REPO --pr 12 --id job-1 --task-id child \
|
|
30
32
|
--adapter chatgpt --provider chatgpt --model gpt-5.6-sol --reasoning high \
|
|
31
33
|
--capabilities model,chatgpt,browser-harness
|
|
34
|
+
|
|
35
|
+
# Explicitly release a held DRAFT task
|
|
36
|
+
npx agents-relay task update --repo OWNER/REPO --pr 12 --id job-1 --task-id child --queue
|
|
32
37
|
~~~
|
|
33
38
|
|
|
39
|
+
Jobs separate durable lifecycle from derived runtime status. Durable lifecycle is `DRAFT | ACTIVE | COMPLETED | CANCELLED`: a newly created unscheduled Job is DRAFT, creating executable work activates it, and scheduled Jobs are ACTIVE because the scheduler is allowed to advance them. Runtime status (`IDLE`, `WAITING`, `QUEUED`, `RUNNING`, `BLOCKED`, `FAILED`, `REVIEWING`, `MERGE_PENDING`, `MERGED`, or `CANCELLED`) is computed from Tasks, Review, PR, and schedule facts and is never persisted as lifecycle truth.
|
|
40
|
+
|
|
34
41
|
Jobs default to `fixed`. `job create` persists only the durable job and never
|
|
35
42
|
creates executable tasks. The top-level orchestrator decides whether the user's
|
|
36
43
|
request should start immediately and, if so, creates the first task batch
|
|
@@ -150,6 +157,6 @@ npx agents-relay task create --repo OWNER/REPO --pr 5 --id job-1 \
|
|
|
150
157
|
--input "Research the dashboard UX and write the final report to the declared output file."
|
|
151
158
|
~~~
|
|
152
159
|
|
|
153
|
-
GitHub PR state is authoritative for terminal lifecycle: a merged PR reconciles its managed job to `COMPLETED`; a closed, unmerged PR reconciles to `CANCELLED` and cannot launch queued work. The dashboard exposes `/api/jobs` and shows
|
|
160
|
+
GitHub PR state is authoritative for terminal lifecycle: a merged PR reconciles its managed job to `COMPLETED`; a closed, unmerged PR reconciles to `CANCELLED` and cannot launch queued work. The dashboard exposes `/api/jobs` and shows PR state, durable Job lifecycle, and derived runtime status separately. A merged PR reconciles lifecycle to `COMPLETED`; a closed, unmerged PR reconciles lifecycle to `CANCELLED`.
|
|
154
161
|
|
|
155
162
|
For work that was completed outside the relay but must be represented truthfully in durable history, use `npx agents-relay record --task-id <id> --summary <summary> --commit <sha>`. Recorded tasks use the `orchestrator` adapter and terminal `SUCCEEDED` state; they do not pretend a shell/model worker executed the work. This is a repair/backfill mechanism—normal work should be created as a durable Task before execution. Managed jobs also retain the GitHub PR body as the job description for dashboard display.
|
package/dist/cli.js
CHANGED
|
@@ -15,7 +15,7 @@ import { serveDashboard } from './dashboard.js';
|
|
|
15
15
|
import { codexAndZaiUsageRegistry } from './usage.js';
|
|
16
16
|
import { discoverAgents } from './registry.js';
|
|
17
17
|
import { AgentObjectivePlanner, CommandObjectivePlanner } from './planner.js';
|
|
18
|
-
import { extendRetryBudget, parseJobSchedule } from './scheduler.js';
|
|
18
|
+
import { extendRetryBudget, parseJobSchedule, transitionTask } from './scheduler.js';
|
|
19
19
|
import { dueBuckets, processDueScheduledJobs, syncScheduleLabels } from './scheduled-jobs.js';
|
|
20
20
|
import { resolveExecutionContext } from './execution-context.js';
|
|
21
21
|
import { defaultWorkspaceRoot, resolveManagedProjectPath } from './workspace.js';
|
|
@@ -27,7 +27,7 @@ Usage:
|
|
|
27
27
|
npx agents-relay <command> [options]
|
|
28
28
|
|
|
29
29
|
Commands:
|
|
30
|
-
job create|update|show|list Manage durable GitHub-backed jobs
|
|
30
|
+
job create|update|show|list|merge|cancel Manage durable GitHub-backed jobs
|
|
31
31
|
task create|update|show|list|retry|cancel Manage durable tasks
|
|
32
32
|
context resolve Resolve shared project instructions, skills, agents, and runtimes
|
|
33
33
|
init Create a local demo job or initialize a GitHub job
|
|
@@ -43,13 +43,15 @@ Use 'node dist/cli.js' only for explicit local development/demo mode.`,
|
|
|
43
43
|
job: `Manage GitHub-backed durable jobs.
|
|
44
44
|
|
|
45
45
|
Usage:
|
|
46
|
-
npx agents-relay job <create|update|show|list> [options]
|
|
46
|
+
npx agents-relay job <create|update|show|list|merge|cancel> [options]
|
|
47
47
|
|
|
48
48
|
Actions:
|
|
49
49
|
create Create a durable Job, optionally binding an existing unmanaged PR
|
|
50
50
|
update Partially update mutable metadata on a healthy existing Job
|
|
51
51
|
show Show one durable Job
|
|
52
52
|
list List managed Jobs in a repository
|
|
53
|
+
merge Merge the backing PR and complete the Job
|
|
54
|
+
cancel Close the backing PR and cancel the Job
|
|
53
55
|
|
|
54
56
|
Common options:
|
|
55
57
|
--repo OWNER/REPO GitHub repository
|
|
@@ -87,22 +89,28 @@ Required: --repo OWNER/REPO.
|
|
|
87
89
|
|
|
88
90
|
Example:
|
|
89
91
|
npx agents-relay job list --repo OWNER/REPO`,
|
|
92
|
+
'job merge': `Merge the backing pull request and complete the durable Job.
|
|
93
|
+
|
|
94
|
+
Required: --repo OWNER/REPO, --pr NUMBER, and --id JOB_ID. The current PR head SHA is pinned into the merge request.`,
|
|
95
|
+
'job cancel': `Cancel the durable Job and close its backing pull request.
|
|
96
|
+
|
|
97
|
+
Required: --repo OWNER/REPO, --pr NUMBER, and --id JOB_ID. Nonterminal Tasks are cancelled before the PR is closed.`,
|
|
90
98
|
task: `Manage durable tasks.
|
|
91
99
|
|
|
92
100
|
Usage:
|
|
93
101
|
npx agents-relay task <create|update|show|list|retry|cancel> [options]
|
|
94
102
|
|
|
95
103
|
Actions:
|
|
96
|
-
create Create one
|
|
104
|
+
create Create one durable Task under an existing Job
|
|
97
105
|
update Update mutable Task fields
|
|
98
106
|
show Show one Task
|
|
99
107
|
list List Tasks for a Job
|
|
100
108
|
retry Retry a failed or blocked Task
|
|
101
109
|
cancel Cancel a Task`,
|
|
102
|
-
'task create': `Create one
|
|
110
|
+
'task create': `Create one durable Task under an existing Job.
|
|
103
111
|
|
|
104
112
|
Required: normal Job storage options plus --input TEXT and --output task-pr|file. Codex/ChatGPT tasks also require --model MODEL.
|
|
105
|
-
Common: --task-id ID, --adapter codex|chatgpt|orchestrator, --priority P0|P1|P2|P3, --story-size S|M|L, --deps a,b, --parent ID.
|
|
113
|
+
Common: --task-id ID, --adapter codex|chatgpt|orchestrator, --priority P0|P1|P2|P3, --story-size S|M|L, --deps a,b, --parent ID, --draft.\nTasks with dependencies start DRAFT; tasks without dependencies start QUEUED unless --draft is given.
|
|
106
114
|
Directly-created Tasks are not wrapped in a synthetic Plan. Planner-generated Tasks are created internally by Relay from planner output.
|
|
107
115
|
|
|
108
116
|
Example:
|
|
@@ -110,7 +118,7 @@ Example:
|
|
|
110
118
|
'task update': `Update mutable durable Task fields in place.
|
|
111
119
|
|
|
112
120
|
Required: --task-id ID and the normal Job storage options.
|
|
113
|
-
Mutable while not RUNNING/SUCCEEDED/CANCELLED: --adapter, --priority, --story-size, --input, --project, --agent, --parent, --deps, --capabilities, --timeout, --max-attempts, --provider, --model, --profile, --reasoning, --cwd, --chatgpt-project, --chatgpt-tab-close-policy.
|
|
121
|
+
Mutable while not RUNNING/SUCCEEDED/CANCELLED: --adapter, --priority, --story-size, --input, --project, --agent, --parent, --deps, --capabilities, --timeout, --max-attempts, --provider, --model, --profile, --reasoning, --cwd, --chatgpt-project, --chatgpt-tab-close-policy, --draft, --queue.
|
|
114
122
|
Use --clear-routing to remove routing metadata.
|
|
115
123
|
|
|
116
124
|
Example:
|
|
@@ -307,7 +315,7 @@ export async function ensureManagedGitHubJob(client, repositoryName, trustedAuth
|
|
|
307
315
|
reviewEnabled: options.reviewEnabled ?? true,
|
|
308
316
|
prNumber: pr.number,
|
|
309
317
|
repository: repositoryName,
|
|
310
|
-
state: '
|
|
318
|
+
state: options.schedule ? 'ACTIVE' : 'DRAFT',
|
|
311
319
|
continuation: options.continuation ?? null,
|
|
312
320
|
createdAt: now,
|
|
313
321
|
updatedAt: now,
|
|
@@ -340,6 +348,7 @@ function workerTaskFromArgs(job, args, now) {
|
|
|
340
348
|
const routing = adapter === 'orchestrator' ? undefined : { provider: arg(args, '--provider', 'openai'), model, profile: arg(args, '--profile') || undefined, reasoning: arg(args, '--reasoning') || undefined, cwd: arg(args, '--cwd') || undefined, projectId: arg(args, '--chatgpt-project') || undefined, decidedBy: 'cli', decidedAt: now };
|
|
341
349
|
const closePolicy = hasArg(args, '--chatgpt-tab-close-policy') ? chatGptTabClosePolicy(args) : undefined;
|
|
342
350
|
const size = storySize(args);
|
|
351
|
+
const dependencies = arg(args, '--deps').split(',').filter(Boolean);
|
|
343
352
|
const timeoutMs = adapter === 'orchestrator' ? 0 : hasArg(args, '--timeout') ? integerArg(args, '--timeout', taskOverallTimeoutMs(size, routing?.reasoning)) : taskOverallTimeoutMs(size, routing?.reasoning);
|
|
344
353
|
return {
|
|
345
354
|
jobId: job.id,
|
|
@@ -349,7 +358,7 @@ function workerTaskFromArgs(job, args, now) {
|
|
|
349
358
|
projectName: arg(args, '--project') || undefined,
|
|
350
359
|
agentName: arg(args, '--agent') || undefined,
|
|
351
360
|
parentTaskId: arg(args, '--parent') || null,
|
|
352
|
-
dependencies
|
|
361
|
+
dependencies,
|
|
353
362
|
capabilities: arg(args, '--capabilities').split(',').filter(Boolean),
|
|
354
363
|
adapter,
|
|
355
364
|
chatgptTabClosePolicy: closePolicy,
|
|
@@ -359,7 +368,7 @@ function workerTaskFromArgs(job, args, now) {
|
|
|
359
368
|
routing,
|
|
360
369
|
continuation: continuation(args),
|
|
361
370
|
continuationDeliveredAt: null,
|
|
362
|
-
state: 'QUEUED',
|
|
371
|
+
state: hasArg(args, '--draft') || dependencies.length > 0 ? 'DRAFT' : 'QUEUED',
|
|
363
372
|
attempt: 0,
|
|
364
373
|
maxAttempts: Number(arg(args, '--max-attempts', '3')),
|
|
365
374
|
leaseOwner: null,
|
|
@@ -385,13 +394,15 @@ async function runTaskCommand(action, args) {
|
|
|
385
394
|
if (!id)
|
|
386
395
|
throw new Error(`task ${action} requires --task-id`);
|
|
387
396
|
if (action === 'create') {
|
|
397
|
+
if (['COMPLETED', 'CANCELLED'].includes(job.state))
|
|
398
|
+
throw new Error(`Cannot create task on terminal job ${job.id} (${job.state})`);
|
|
388
399
|
const now = new Date().toISOString();
|
|
389
400
|
const task = workerTaskFromArgs(job, args, now);
|
|
390
401
|
task.id = id;
|
|
391
402
|
await store.appendTask(task);
|
|
392
403
|
const refreshed = await store.load(job.id);
|
|
393
|
-
if (refreshed.state
|
|
394
|
-
refreshed.state = '
|
|
404
|
+
if (refreshed.state === 'DRAFT') {
|
|
405
|
+
refreshed.state = 'ACTIVE';
|
|
395
406
|
refreshed.updatedAt = now;
|
|
396
407
|
await store.saveJob(refreshed);
|
|
397
408
|
}
|
|
@@ -415,8 +426,8 @@ async function runTaskCommand(action, args) {
|
|
|
415
426
|
task.error = action === 'retry' ? null : 'Cancelled by user';
|
|
416
427
|
task.updatedAt = new Date().toISOString();
|
|
417
428
|
await store.saveTask(task);
|
|
418
|
-
if (action === 'retry' && job.state
|
|
419
|
-
job.state = '
|
|
429
|
+
if (action === 'retry' && job.state === 'DRAFT') {
|
|
430
|
+
job.state = 'ACTIVE';
|
|
420
431
|
job.updatedAt = task.updatedAt;
|
|
421
432
|
await store.saveJob(job);
|
|
422
433
|
}
|
|
@@ -456,6 +467,16 @@ async function runTaskCommand(action, args) {
|
|
|
456
467
|
task.chatgptTabClosePolicy = chatGptTabClosePolicy(args);
|
|
457
468
|
task.timeoutMs = integerArg(args, '--timeout', task.timeoutMs);
|
|
458
469
|
task.maxAttempts = integerArg(args, '--max-attempts', task.maxAttempts);
|
|
470
|
+
if (hasArg(args, '--draft') && hasArg(args, '--queue'))
|
|
471
|
+
throw new Error('--draft and --queue are mutually exclusive');
|
|
472
|
+
if (hasArg(args, '--draft'))
|
|
473
|
+
transitionTask(task, 'DRAFT');
|
|
474
|
+
if (hasArg(args, '--queue')) {
|
|
475
|
+
const taskMap = new Map(job.tasks.map(item => [item.id, item]));
|
|
476
|
+
if (!task.dependencies.every(dep => taskMap.get(dep)?.state === 'SUCCEEDED'))
|
|
477
|
+
throw new Error('Task dependencies must succeed before queueing');
|
|
478
|
+
transitionTask(task, 'QUEUED');
|
|
479
|
+
}
|
|
459
480
|
if (hasArg(args, '--max-attempts') && task.maxAttempts < task.attempt)
|
|
460
481
|
throw new Error('--max-attempts cannot be less than the current attempt count');
|
|
461
482
|
const routingFlags = ['--provider', '--model', '--profile', '--reasoning', '--cwd', '--chatgpt-project'];
|
|
@@ -504,11 +525,11 @@ async function runContextCommand(action, args) {
|
|
|
504
525
|
console.log(JSON.stringify(context, null, 2));
|
|
505
526
|
}
|
|
506
527
|
async function runJobCommand(action, args) {
|
|
507
|
-
if (!['create', 'update', 'show', 'list'].includes(action))
|
|
528
|
+
if (!['create', 'update', 'show', 'list', 'merge', 'cancel'].includes(action))
|
|
508
529
|
throw new Error(usage);
|
|
509
530
|
const repo = repository(args);
|
|
510
531
|
if (action === 'create') {
|
|
511
|
-
const taskOnlyFlags = ['--start', '--task-id', '--input', '--adapter', '--provider', '--model', '--profile', '--reasoning', '--story-size', '--project', '--agent', '--parent', '--deps', '--capabilities', '--attachments', '--max-attempts', '--timeout', '--cwd', '--chatgpt-project', '--chatgpt-tab-close-policy', '--output', '--output-path'];
|
|
532
|
+
const taskOnlyFlags = ['--start', '--task-id', '--input', '--adapter', '--provider', '--model', '--profile', '--reasoning', '--story-size', '--project', '--agent', '--parent', '--deps', '--capabilities', '--attachments', '--max-attempts', '--timeout', '--cwd', '--chatgpt-project', '--chatgpt-tab-close-policy', '--output', '--output-path', '--draft', '--queue'];
|
|
512
533
|
const invalid = taskOnlyFlags.find(flag => hasArg(args, flag));
|
|
513
534
|
if (invalid)
|
|
514
535
|
throw new Error(`job create does not accept task execution option ${invalid}; create the job first, then let the orchestrator create the initial plan/task batch`);
|
|
@@ -555,6 +576,34 @@ async function runJobCommand(action, args) {
|
|
|
555
576
|
return;
|
|
556
577
|
}
|
|
557
578
|
await store.validateJobBinding(id);
|
|
579
|
+
if (action === 'merge') {
|
|
580
|
+
if (job.state === 'CANCELLED')
|
|
581
|
+
throw new Error(`Cannot merge cancelled job ${job.id}`);
|
|
582
|
+
const headSha = await store.headSha();
|
|
583
|
+
await store.mergePullRequest(headSha ?? undefined);
|
|
584
|
+
job.state = 'COMPLETED';
|
|
585
|
+
job.updatedAt = new Date().toISOString();
|
|
586
|
+
await store.saveJob(job);
|
|
587
|
+
console.log(JSON.stringify(await store.load(id), null, 2));
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
if (action === 'cancel') {
|
|
591
|
+
if (job.state === 'COMPLETED')
|
|
592
|
+
throw new Error(`Cannot cancel completed job ${job.id}`);
|
|
593
|
+
for (const task of job.tasks.filter(item => !['SUCCEEDED', 'CANCELLED'].includes(item.state))) {
|
|
594
|
+
transitionTask(task, 'CANCELLED');
|
|
595
|
+
task.error = 'Job cancelled';
|
|
596
|
+
task.leaseOwner = null;
|
|
597
|
+
task.leaseExpiresAt = null;
|
|
598
|
+
await store.saveTask(task);
|
|
599
|
+
}
|
|
600
|
+
await store.closePullRequest();
|
|
601
|
+
job.state = 'CANCELLED';
|
|
602
|
+
job.updatedAt = new Date().toISOString();
|
|
603
|
+
await store.saveJob(job);
|
|
604
|
+
console.log(JSON.stringify(await store.load(id), null, 2));
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
558
607
|
if (hasArg(args, '--title'))
|
|
559
608
|
job.title = arg(args, '--title');
|
|
560
609
|
if (hasArg(args, '--objective'))
|
|
@@ -689,7 +738,7 @@ async function main() {
|
|
|
689
738
|
}
|
|
690
739
|
if (command === 'init') {
|
|
691
740
|
const now = new Date().toISOString();
|
|
692
|
-
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: '
|
|
741
|
+
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: scheduleFromArgs(args) ? 'ACTIVE' : 'DRAFT', continuation: continuation(args), createdAt: now, updatedAt: now, tasks: [] };
|
|
693
742
|
let store;
|
|
694
743
|
let file = null;
|
|
695
744
|
if (arg(args, '--repo') && arg(args, '--pr'))
|
package/dist/dashboard.js
CHANGED
|
@@ -3,6 +3,7 @@ import { readFile } from 'node:fs/promises';
|
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { readUsageSnapshot } from './usage.js';
|
|
6
|
+
import { deriveJobRuntimeStatus } from './runtime-status.js';
|
|
6
7
|
import { DeliveryDeduper, parseGitHubWebhook, verifyGitHubWebhook } from './github-webhook.js';
|
|
7
8
|
const GITHUB_REPOSITORY_PATTERN = /^([A-Za-z0-9](?:[A-Za-z0-9-]{0,36}[A-Za-z0-9])?)\/([A-Za-z0-9._-]{1,100})$/;
|
|
8
9
|
const SSE_HEARTBEAT_MS = 15000;
|
|
@@ -94,10 +95,10 @@ export const dashboardHtml = `<!doctype html>
|
|
|
94
95
|
.job-meta { display:flex; gap:.4rem; flex-wrap:wrap; color:var(--muted); font-size:.7rem; }
|
|
95
96
|
.job-meta span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
96
97
|
.status-pill,.badge { display:inline-flex; align-items:center; border-radius:999px; padding:.06rem .42rem; min-height:1.35rem; font-size:.68rem; font-weight:800; }
|
|
97
|
-
.status-pill.ok,.badge.RUNNING,.badge.SUCCEEDED,.badge.COMPLETED { background:#e5f3e9; color:var(--ok); }
|
|
98
|
-
.status-pill.warn,.badge.BLOCKED,.badge.
|
|
98
|
+
.status-pill.ok,.badge.ACTIVE,.badge.RUNNING,.badge.SUCCEEDED,.badge.COMPLETED,.badge.MERGED { background:#e5f3e9; color:var(--ok); }
|
|
99
|
+
.status-pill.warn,.badge.BLOCKED,.badge.DRAFT,.badge.QUEUED,.badge.WAITING,.badge.OPEN { background:#fff1d2; color:var(--warn); }
|
|
99
100
|
.status-pill.bad,.badge.FAILED { background:#ffe8e8; color:var(--bad); }
|
|
100
|
-
.status-pill.neutral,.badge.CANCELLED { background:#edf0f4; color:var(--muted); }
|
|
101
|
+
.status-pill.neutral,.badge.IDLE,.badge.CANCELLED { background:#edf0f4; color:var(--muted); }
|
|
101
102
|
.badge.DRAFT { background:#eef2ff; color:#3f51b5; }
|
|
102
103
|
.badge.MERGE_PENDING { background:#ffe3a3; color:#7a4a00; }
|
|
103
104
|
.job-summary,.detail-groups { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:.55rem; }
|
|
@@ -220,8 +221,7 @@ function markdownField(label,input){const box=element('div','detail-group');box.
|
|
|
220
221
|
function rememberSelection(){if(selectedJob)sessionStorage.setItem('agents-relay:selected-job',selectedJob);else sessionStorage.removeItem('agents-relay:selected-job');if(selectedTaskId)sessionStorage.setItem('agents-relay:selected-task',selectedTaskId);else sessionStorage.removeItem('agents-relay:selected-task');}
|
|
221
222
|
function selected(){return jobs.find(job=>job.id===selectedJob)||jobs[0]||null;}
|
|
222
223
|
function counts(tasks){const out={};for(const task of Array.isArray(tasks)?tasks:[])out[task.state]=(out[task.state]||0)+1;return out;}
|
|
223
|
-
function compactCounts(input){const order=['RUNNING','BLOCKED','FAILED','QUEUED','
|
|
224
|
-
function awaitingInitialTask(job){return job?.state==='OPEN'&&Array.isArray(job?.tasks)&&job.tasks.length===0;}
|
|
224
|
+
function compactCounts(input){const order=['RUNNING','BLOCKED','FAILED','QUEUED','DRAFT','SUCCEEDED','CANCELLED'];return order.filter(state=>input[state]).map(state=>input[state]+' '+state).join(', ')||'No tasks';}
|
|
225
225
|
function relativeTime(timestamp){if(!timestamp)return '—';const parsed=Date.parse(timestamp);if(!Number.isFinite(parsed))return value(timestamp);const seconds=Math.max(0,Math.round((Date.now()-parsed)/1000));if(seconds<60)return 'just now';if(seconds<3600)return Math.floor(seconds/60)+'m ago';if(seconds<86400)return Math.floor(seconds/3600)+'h ago';return Math.floor(seconds/86400)+'d ago';}
|
|
226
226
|
function safePrUrl(repository,prNumber){if(typeof repository!=='string'||!Number.isInteger(prNumber)||prNumber<=0)return null;const parts=repository.split('/');if(parts.length!==2)return null;const [owner,repo]=parts;if(!owner||!repo||owner==='.'||owner==='..'||repo==='.'||repo==='..')return null;if(!/^[A-Za-z0-9-]+$/.test(owner)||!/^[A-Za-z0-9._-]+$/.test(repo))return null;return 'https://github.com/'+owner+'/'+repo+'/pull/'+prNumber;}
|
|
227
227
|
function prField(job){const box=element('div','detail-group');box.append(element('p','detail-label',text('GitHub PR')));const line=element('p','detail-value');const url=safePrUrl(job.repository,job.prNumber);if(url){const link=document.createElement('a');link.href=url;link.target='_blank';link.rel='noopener noreferrer';link.textContent='PR #'+job.prNumber;line.append(link);}else line.append(text('unavailable'));box.append(line);return box;}
|
|
@@ -237,15 +237,15 @@ function syncFilters(){
|
|
|
237
237
|
selectedRepo=repos.includes(previousRepo)?previousRepo:'';
|
|
238
238
|
repoFilter.value=selectedRepo;
|
|
239
239
|
}
|
|
240
|
-
function renderJobList(){jobList.replaceChildren();const visible=filteredJobs();if(!jobs.length){jobList.append(element('li','empty',text('No durable jobs discovered.')));return;}if(!visible.length){jobList.append(element('li','empty',text('No jobs match the selected repo.')));return;}for(const job of visible){const button=document.createElement('button');button.type='button';button.className='job-button'+(isCompleteUnmerged(job)?' complete-unmerged':'');button.setAttribute('aria-current',String(job.id===selectedJob));button.title=job.title||job.id;button.addEventListener('click',()=>selectJob(job.id));const meta=element('span','job-meta');meta.append(element('span','badge '+value(job.state),text(job.state)),element('span','badge mode-'+value(job.executionMode||'fixed'),text(value(job.executionMode||'fixed').toUpperCase())),element('span',job.draft?'badge DRAFT':'',text(job.draft?'DRAFT':value(job.githubState||'UNKNOWN'))));if(isCompleteUnmerged(job))meta.append(element('span','badge MERGE_PENDING',text('MERGE PENDING')));
|
|
240
|
+
function renderJobList(){jobList.replaceChildren();const visible=filteredJobs();if(!jobs.length){jobList.append(element('li','empty',text('No durable jobs discovered.')));return;}if(!visible.length){jobList.append(element('li','empty',text('No jobs match the selected repo.')));return;}for(const job of visible){const button=document.createElement('button');button.type='button';button.className='job-button'+(isCompleteUnmerged(job)?' complete-unmerged':'');button.setAttribute('aria-current',String(job.id===selectedJob));button.title=job.title||job.id;button.addEventListener('click',()=>selectJob(job.id));const meta=element('span','job-meta');meta.append(element('span','badge '+value(job.runtimeStatus||'IDLE'),text(value(job.runtimeStatus||'IDLE'))),element('span','badge '+value(job.state),text(job.state)),element('span','badge mode-'+value(job.executionMode||'fixed'),text(value(job.executionMode||'fixed').toUpperCase())),element('span',job.draft?'badge DRAFT':'',text(job.draft?'DRAFT':value(job.githubState||'UNKNOWN'))));if(isCompleteUnmerged(job))meta.append(element('span','badge MERGE_PENDING',text('MERGE PENDING')));meta.append(element('span','',text(job.repository||'unknown repo')),element('span','',text(relativeTime(job.updatedAt))));button.append(element('span','job-title',text('PR#'+value(job.prNumber)+': '+value(job.title||job.id))),meta,element('span','subtle',text(compactCounts(counts(job.tasks)))));jobList.append(element('li','',button));}}
|
|
241
241
|
function taskNode(task,children,depth){const li=element('li');const row=document.createElement('button');row.type='button';row.className='task-row';row.setAttribute('aria-current',String(task.id===selectedTaskId));row.title=task.id;row.addEventListener('click',()=>{selectedTaskId=task.id;eventTaskId=task.id;rememberSelection();clearEvents();renderAll();void loadEventHistory();});const id=element('span','task-id',text(task.id));id.style.paddingLeft=Math.min(depth,4)*6+'px';row.append(id,element('span','badge '+value(task.state),text(task.state)));li.append(row);const nested=children.get(task.id)||[];if(nested.length){const ul=element('ul','tree');for(const child of nested)ul.append(taskNode(child,children,depth+1));li.append(ul);}return li;}
|
|
242
242
|
function buildTree(tasks){taskTree.replaceChildren();if(!Array.isArray(tasks)||!tasks.length){taskTree.append(element('li','empty',text('No tasks in durable state.')));return;}const known=new Set(tasks.map(task=>task.id));const children=new Map();const roots=[];for(const task of tasks){if(!task.parentTaskId||!known.has(task.parentTaskId))roots.push(task);else{const siblings=children.get(task.parentTaskId)||[];siblings.push(task);children.set(task.parentTaskId,siblings);}}for(const task of roots)taskTree.append(taskNode(task,children,0));}
|
|
243
243
|
function attemptBudget(task){const attempt=Number(task?.attempt)||0;const max=Number(task?.maxAttempts)||0;if(max>=3&&max%3===0){const effectiveMax=Math.max(max,Math.ceil(attempt/3)*3);return attempt+' / 3×'+(effectiveMax/3);}return attempt+' / '+max;}
|
|
244
244
|
function renderTaskDetail(task){taskDetailRoot.replaceChildren();if(!task){taskDetailRoot.append(element('p','empty',text('Select a task to inspect execution details.')));return;}const routing=task.routing||{};const groups=element('div','detail-groups');groups.append(field('Task',task.id),field('Kind',task.kind||'work'),field('State',task.state),field('Planning decision',task.planningDecisionId||'—'),field('Planning source',task.planningSource||'—'),field('Planning round',task.planningRound===undefined?'—':String(task.planningRound)),field('Project / agent',value(task.projectName)+' / '+value(task.agentName)),field('Parent task',task.parentTaskId||'root'),field('Adapter',task.adapter),field('Provider / model',value(routing.provider)+' / '+value(routing.model)),field('Reasoning / profile',value(routing.reasoning||routing.profile)),field('Attempt',attemptBudget(task)),field('Dependencies',Array.isArray(task.dependencies)&&task.dependencies.length?task.dependencies.join(', '):'none'),chatField(task));if(task.plannerResult)groups.append(field('Objective status',task.plannerResult.objective_status),markdownField('Assessment',task.plannerResult.assessment));if(task.error)groups.append(field('Error',task.error));taskDetailRoot.append(groups);}
|
|
245
245
|
function reviewStatus(job){const review=job?.review;if(!review)return 'Not started';if(review.outcome==='approved'&&review.headSha===job.headSha)return 'MERGE READY · approved';const stale=Array.isArray(job?.tasks)&&job.tasks.some(task=>task.agentName==='reviewer'&&task.reviewHeadSha&&job.headSha&&task.reviewHeadSha!==job.headSha);if(stale)return 'STALE · awaiting review';return String(review.outcome||'pending').replace('_',' ').toUpperCase();}
|
|
246
|
-
function renderSelectedJob(){jobDetail.replaceChildren();const job=selected();if(!job){selectedJobTitle.textContent='';jobDetail.append(element('div','empty',text('No durable jobs discovered.')));buildTree([]);renderTaskDetail(null);return;}selectedJobTitle.textContent=value(job.title||job.id);if(job.id!==selectedJob){selectedJob=job.id;selectedTaskId=null;eventTaskId=null;clearEvents();rememberSelection();}const summary=element('div','job-summary');summary.append(field('
|
|
246
|
+
function renderSelectedJob(){jobDetail.replaceChildren();const job=selected();if(!job){selectedJobTitle.textContent='';jobDetail.append(element('div','empty',text('No durable jobs discovered.')));buildTree([]);renderTaskDetail(null);return;}selectedJobTitle.textContent=value(job.title||job.id);if(job.id!==selectedJob){selectedJob=job.id;selectedTaskId=null;eventTaskId=null;clearEvents();rememberSelection();}const summary=element('div','job-summary');summary.append(field('PR state',job.githubState||'UNKNOWN'),field('Lifecycle state',job.state),field('Runtime status',job.runtimeStatus||'IDLE'),field('Execution mode',job.executionMode||'fixed'),field('Review',reviewStatus(job)),field('Review head SHA',job.review?.headSha||job.headSha||'—'),prField(job),field('Repository',job.repository||'—'),field('Last activity',relativeTime(job.updatedAt)),field('Task states',compactCounts(job.tasks)));jobDetail.append(summary);const selectedTask=(job.tasks||[]).find(task=>task.id===selectedTaskId)||null;if(selectedTaskId&&!selectedTask){selectedTaskId=null;eventTaskId=null;clearEvents();rememberSelection();}buildTree(job.tasks||[]);renderTaskDetail(selectedTask);renderEventScope();}
|
|
247
247
|
function renderAll(){jobs.sort(compareJobs);syncFilters();renderJobList();renderSelectedJob();}
|
|
248
|
-
function isCompleteUnmerged(job){return job.
|
|
248
|
+
function isCompleteUnmerged(job){return job.runtimeStatus==='MERGE_PENDING';}
|
|
249
249
|
function isTerminalJob(job){return ['COMPLETED','CANCELLED'].includes(job.state)||['MERGED','CLOSED'].includes(job.githubState);}
|
|
250
250
|
function compareJobs(a,b){const mergePendingDelta=Number(isCompleteUnmerged(b))-Number(isCompleteUnmerged(a));if(mergePendingDelta)return mergePendingDelta;const terminalDelta=Number(isTerminalJob(a))-Number(isTerminalJob(b));return terminalDelta||String(b.updatedAt).localeCompare(String(a.updatedAt));}
|
|
251
251
|
async function loadJSON(url){const response=await fetch(url);if(!response.ok)throw new Error(response.status+' '+response.statusText);return response.json();}
|
|
@@ -295,7 +295,7 @@ export function serveDashboard(store, port = 8787, bus, refreshMs = 300000, jobI
|
|
|
295
295
|
const discoveredJobs = async (forceRefresh = false) => {
|
|
296
296
|
try {
|
|
297
297
|
const jobs = overviewProvider
|
|
298
|
-
? (await overviewProvider(forceRefresh)).map(item => ({ ...item.job, githubState: item.githubState, mergedAt: item.mergedAt, closedAt: item.closedAt, draft: item.draft }))
|
|
298
|
+
? (await overviewProvider(forceRefresh)).map(item => ({ ...item.job, githubState: item.githubState, runtimeStatus: deriveJobRuntimeStatus(item.job, item.githubState), mergedAt: item.mergedAt, closedAt: item.closedAt, draft: item.draft }))
|
|
299
299
|
: jobId ? [await store.load(jobId)] : [];
|
|
300
300
|
lastKnownJobs = jobs;
|
|
301
301
|
return jobs;
|
package/dist/pool.js
CHANGED
|
@@ -2,12 +2,12 @@ import { GitHubStore, listManagedGitHubJobs } from './store.js';
|
|
|
2
2
|
import { eventFor } from './events.js';
|
|
3
3
|
const RANK = { P0: 0, P1: 1, P2: 2, P3: 3 };
|
|
4
4
|
export function compareJobPriority(a, b) { return RANK[a.priority ?? 'P2'] - RANK[b.priority ?? 'P2'] || a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id); }
|
|
5
|
-
export function runnableManagedJobs(items) { return items.filter(item => item.githubState === 'OPEN' &&
|
|
5
|
+
export function runnableManagedJobs(items) { return items.filter(item => item.githubState === 'OPEN' && item.job.state === 'ACTIVE').map(item => item.job).sort(compareJobPriority); }
|
|
6
6
|
export function needsLifecycleReconciliation(job, now = Date.now()) {
|
|
7
|
-
//
|
|
7
|
+
// DRAFT create-only Jobs stay dormant until a caller explicitly creates work.
|
|
8
8
|
if (job.tasks.length === 0)
|
|
9
9
|
return false;
|
|
10
|
-
if (job.tasks.some(task =>
|
|
10
|
+
if (job.tasks.some(task => task.state === 'QUEUED' || (task.state === 'DRAFT' && task.dependencies.length > 0)))
|
|
11
11
|
return true;
|
|
12
12
|
return job.tasks.some(task => task.state === 'RUNNING' && task.leaseExpiresAt !== null && new Date(task.leaseExpiresAt).getTime() <= now);
|
|
13
13
|
}
|
|
@@ -48,7 +48,7 @@ export class RepositoryWorkerPool {
|
|
|
48
48
|
if (capacity <= 0)
|
|
49
49
|
break;
|
|
50
50
|
const store = new GitHubStore(this.client, job.repository, job.prNumber, this.trustedAuthors);
|
|
51
|
-
const ready = job.tasks.filter(task =>
|
|
51
|
+
const ready = job.tasks.filter(task => task.state === 'QUEUED' || (task.state === 'DRAFT' && task.dependencies.length > 0)).length;
|
|
52
52
|
const expiredRunning = job.tasks.some(task => task.state === 'RUNNING' && task.leaseExpiresAt !== null && new Date(task.leaseExpiresAt).getTime() <= Date.now());
|
|
53
53
|
if (!needsLifecycleReconciliation(job))
|
|
54
54
|
continue;
|
package/dist/reconciler.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { storySizeTimeoutMs, taskOverallTimeoutMs, chatGptStartAckTimeoutMs } from './types.js';
|
|
3
3
|
import { dependencyReady, schedule, transitionTask } from './scheduler.js';
|
|
4
|
+
import { implementationComplete } from './runtime-status.js';
|
|
4
5
|
import { LaunchFailure, StartAckTimeout } from './adapters.js';
|
|
5
6
|
import { eventFor } from './events.js';
|
|
6
7
|
import { parsePlannerResult, plannerInput } from './planner.js';
|
|
@@ -129,7 +130,16 @@ export class Reconciler {
|
|
|
129
130
|
task.leaseExpiresAt = null;
|
|
130
131
|
await this.store.saveTask(task);
|
|
131
132
|
}
|
|
133
|
+
const terminalState = prState === 'MERGED' ? 'COMPLETED' : 'CANCELLED';
|
|
134
|
+
if (job.state !== terminalState) {
|
|
135
|
+
job.state = terminalState;
|
|
136
|
+
job.updatedAt = new Date().toISOString();
|
|
137
|
+
await this.store.saveJob(job);
|
|
138
|
+
}
|
|
139
|
+
return this.store.load(jobId);
|
|
132
140
|
}
|
|
141
|
+
if (job.state !== 'ACTIVE')
|
|
142
|
+
return job;
|
|
133
143
|
await this.recoverLostExecutions(job, now);
|
|
134
144
|
await this.recoverPlannerChildren(job);
|
|
135
145
|
await this.routeExecutableTasks(await this.store.load(jobId));
|
|
@@ -138,7 +148,11 @@ export class Reconciler {
|
|
|
138
148
|
this.live.get(task.id)?.execution.cancel();
|
|
139
149
|
this.plannerLive.get(task.id)?.controller.abort();
|
|
140
150
|
}
|
|
141
|
-
|
|
151
|
+
const beforeSchedule = await this.store.load(jobId);
|
|
152
|
+
const scheduleBefore = JSON.stringify(beforeSchedule.schedule ?? null);
|
|
153
|
+
let scheduled = schedule(beforeSchedule);
|
|
154
|
+
if (scheduled.state !== previousState || JSON.stringify(scheduled.schedule ?? null) !== scheduleBefore)
|
|
155
|
+
await this.store.saveJob(scheduled);
|
|
142
156
|
if (prState === 'OPEN') {
|
|
143
157
|
await this.ensureFinalReview(scheduled, prState, headSha);
|
|
144
158
|
scheduled = schedule(await this.store.load(jobId));
|
|
@@ -146,15 +160,7 @@ export class Reconciler {
|
|
|
146
160
|
if (prState === 'OPEN' && scheduled.executionMode === 'autonomous') {
|
|
147
161
|
scheduled = schedule(await this.maybeCreatePlanner(scheduled));
|
|
148
162
|
}
|
|
149
|
-
if (
|
|
150
|
-
scheduled.state = 'COMPLETED';
|
|
151
|
-
scheduled.updatedAt = new Date().toISOString();
|
|
152
|
-
}
|
|
153
|
-
if (prState === 'CLOSED') {
|
|
154
|
-
scheduled.state = 'CANCELLED';
|
|
155
|
-
scheduled.updatedAt = new Date().toISOString();
|
|
156
|
-
}
|
|
157
|
-
if (scheduled.state !== previousState)
|
|
163
|
+
if (scheduled.state !== previousState || scheduled.updatedAt !== job.updatedAt)
|
|
158
164
|
await this.store.saveJob(scheduled);
|
|
159
165
|
if (prState === 'OPEN') {
|
|
160
166
|
for (const task of scheduled.tasks.filter(item => item.state === 'SUCCEEDED' && (item.continuation || scheduled.continuation) && !item.continuationDeliveredAt))
|
|
@@ -176,17 +182,13 @@ export class Reconciler {
|
|
|
176
182
|
}
|
|
177
183
|
}
|
|
178
184
|
const finalState = schedule(await this.store.load(jobId));
|
|
179
|
-
if (
|
|
180
|
-
finalState.state = 'COMPLETED';
|
|
181
|
-
finalState.updatedAt = new Date().toISOString();
|
|
182
|
-
}
|
|
183
|
-
if (prState === 'CLOSED') {
|
|
184
|
-
finalState.state = 'CANCELLED';
|
|
185
|
-
finalState.updatedAt = new Date().toISOString();
|
|
186
|
-
}
|
|
187
|
-
if (finalState.state !== scheduled.state || finalState.updatedAt !== scheduled.updatedAt)
|
|
185
|
+
if (finalState.updatedAt !== scheduled.updatedAt)
|
|
188
186
|
await this.store.saveJob(finalState);
|
|
189
187
|
await this.ensureFinalReview(finalState, prState, headSha);
|
|
188
|
+
if (prState === 'OPEN' && finalState.reviewEnabled === false && implementationComplete(finalState) && finalState.autoMerge !== false && this.store.mergePullRequest && headSha) {
|
|
189
|
+
await this.store.mergePullRequest(headSha);
|
|
190
|
+
await this.emit(eventFor(finalState.id, 'job', null, 'job.merged', 'succeeded', 'Completed work merged PR #' + finalState.prNumber, 'user', { headSha, autoMerge: true }, { agent: 'relay', component: 'agents-relay' }));
|
|
191
|
+
}
|
|
190
192
|
return this.store.load(jobId);
|
|
191
193
|
}
|
|
192
194
|
finally {
|
|
@@ -199,7 +201,7 @@ export class Reconciler {
|
|
|
199
201
|
return;
|
|
200
202
|
job.review = { ...job.review, outcome: 'stale', updatedAt: new Date().toISOString() };
|
|
201
203
|
const staleTask = job.review.taskId ? job.tasks.find(task => task.id === job.review?.taskId) : undefined;
|
|
202
|
-
if (staleTask && ['
|
|
204
|
+
if (staleTask && ['DRAFT', 'QUEUED', 'RUNNING'].includes(staleTask.state)) {
|
|
203
205
|
this.live.get(staleTask.id)?.execution.cancel();
|
|
204
206
|
transitionTask(staleTask, 'CANCELLED');
|
|
205
207
|
staleTask.error = 'Review evidence became stale after the PR head changed';
|
|
@@ -210,7 +212,7 @@ export class Reconciler {
|
|
|
210
212
|
await this.store.saveJob(job);
|
|
211
213
|
}
|
|
212
214
|
async ensureFinalReview(job, prState, headSha) {
|
|
213
|
-
if (job.schedule || job.reviewEnabled === false || prState !== 'OPEN' || job.state !== '
|
|
215
|
+
if (job.schedule || job.reviewEnabled === false || prState !== 'OPEN' || job.state !== 'ACTIVE' || !implementationComplete(job) || !headSha)
|
|
214
216
|
return;
|
|
215
217
|
if (!this.preReviewGatesPass(job))
|
|
216
218
|
return;
|
|
@@ -218,7 +220,7 @@ export class Reconciler {
|
|
|
218
220
|
if (review?.headSha === headSha && ['started', 'approved', 'changes_required', 'blocked'].includes(review.outcome))
|
|
219
221
|
return;
|
|
220
222
|
const reviewTasks = job.tasks.filter(task => task.agentName === 'reviewer' && task.reviewHeadSha === headSha);
|
|
221
|
-
if (reviewTasks.some(task => ['
|
|
223
|
+
if (reviewTasks.some(task => ['DRAFT', 'QUEUED', 'RUNNING'].includes(task.state)))
|
|
222
224
|
return;
|
|
223
225
|
if (reviewTasks.some(task => task.state === 'SUCCEEDED' && review?.headSha === headSha && review.outcome === 'approved'))
|
|
224
226
|
return;
|
|
@@ -229,7 +231,6 @@ export class Reconciler {
|
|
|
229
231
|
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 };
|
|
230
232
|
await this.store.appendTask(task);
|
|
231
233
|
job.review = { outcome: 'pending', headSha, taskId: id, evidence: null, updatedAt: now };
|
|
232
|
-
job.state = 'RUNNING';
|
|
233
234
|
job.updatedAt = now;
|
|
234
235
|
await this.store.saveJob(job);
|
|
235
236
|
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 } }));
|
|
@@ -340,7 +341,6 @@ export class Reconciler {
|
|
|
340
341
|
await this.store.appendTask(fixTask);
|
|
341
342
|
await this.emit(eventFor(job.id, id, task.id, 'review.fix_task_created', 'queued', `Created one fix task ${id} for ${issues.length} reviewer finding(s)`, 'orchestrator', { headSha, issues }, { agent: 'relay', component: 'agents-relay' }));
|
|
342
343
|
}
|
|
343
|
-
job.state = 'RUNNING';
|
|
344
344
|
}
|
|
345
345
|
job.updatedAt = job.review.updatedAt;
|
|
346
346
|
await this.store.saveJob(job);
|
|
@@ -391,7 +391,7 @@ export class Reconciler {
|
|
|
391
391
|
const slot = job.schedule?.currentRunSlot ? Date.parse(job.schedule.currentRunSlot) : Number.NaN;
|
|
392
392
|
const scopedTasks = Number.isFinite(slot) ? job.tasks.filter(task => Date.parse(task.createdAt) >= slot) : job.tasks;
|
|
393
393
|
const planners = scopedTasks.filter(task => task.kind === 'planner').sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
|
|
394
|
-
if (planners.some(task => ['
|
|
394
|
+
if (planners.some(task => ['DRAFT', 'QUEUED', 'RUNNING'].includes(task.state)))
|
|
395
395
|
return job;
|
|
396
396
|
if (scopedTasks.some(task => task.state === 'FAILED' || task.state === 'BLOCKED'))
|
|
397
397
|
return job;
|
|
@@ -645,7 +645,7 @@ export class Reconciler {
|
|
|
645
645
|
}
|
|
646
646
|
plannerChild(job, planner, spec) {
|
|
647
647
|
const now = new Date().toISOString();
|
|
648
|
-
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 };
|
|
648
|
+
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: (spec.dependencies?.length ?? 0) > 0 ? 'DRAFT' : '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 };
|
|
649
649
|
}
|
|
650
650
|
clearExecution(taskId, executionId) {
|
|
651
651
|
const live = this.live.get(taskId);
|
package/dist/relayd.js
CHANGED
|
@@ -14,6 +14,7 @@ import { githubAuthContext } from './github-auth.js';
|
|
|
14
14
|
import { isEntrypoint, runtimePlanner, runtimeReviewer, startService, SERVICE_DEFAULTS } from './cli.js';
|
|
15
15
|
import { codexAndZaiUsageRegistry } from './usage.js';
|
|
16
16
|
import { defaultWorkspaceRoot, discoverWorkspaceRepositories, resolveManagedProjectPath } from './workspace.js';
|
|
17
|
+
import { deriveJobRuntimeStatus } from './runtime-status.js';
|
|
17
18
|
export const daemonHelp = `Agents Relay daemon runs the dashboard and worker pool.
|
|
18
19
|
|
|
19
20
|
Usage:
|
|
@@ -151,7 +152,7 @@ export async function runDaemon(argv) {
|
|
|
151
152
|
const refreshed = (await refreshPullRequest(cached.job.repository, cached.job.prNumber)).find(item => item.job.id === jobId);
|
|
152
153
|
if (!refreshed)
|
|
153
154
|
return null;
|
|
154
|
-
return { ...refreshed.job, githubState: refreshed.githubState, mergedAt: refreshed.mergedAt, closedAt: refreshed.closedAt, draft: refreshed.draft };
|
|
155
|
+
return { ...refreshed.job, githubState: refreshed.githubState, runtimeStatus: deriveJobRuntimeStatus(refreshed.job, refreshed.githubState), mergedAt: refreshed.mergedAt, closedAt: refreshed.closedAt, draft: refreshed.draft };
|
|
155
156
|
}
|
|
156
157
|
catch (error) {
|
|
157
158
|
process.stderr.write(`warning: selected job refresh unavailable: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
function isSuperseded(task) {
|
|
2
|
+
return task.state === 'CANCELLED' && typeof task.error === 'string' && task.error.startsWith('Superseded ');
|
|
3
|
+
}
|
|
4
|
+
export function implementationTasks(job) {
|
|
5
|
+
return job.tasks.filter(task => task.agentName !== 'reviewer' && !isSuperseded(task));
|
|
6
|
+
}
|
|
7
|
+
export function implementationComplete(job) {
|
|
8
|
+
const tasks = implementationTasks(job);
|
|
9
|
+
return tasks.length > 0 && tasks.every(task => task.state === 'SUCCEEDED');
|
|
10
|
+
}
|
|
11
|
+
export function deriveJobRuntimeStatus(job, prState = 'OPEN') {
|
|
12
|
+
if (prState === 'MERGED')
|
|
13
|
+
return 'MERGED';
|
|
14
|
+
if (prState === 'CLOSED' || job.state === 'CANCELLED')
|
|
15
|
+
return 'CANCELLED';
|
|
16
|
+
if (job.state === 'DRAFT')
|
|
17
|
+
return 'IDLE';
|
|
18
|
+
if (job.schedule) {
|
|
19
|
+
if (job.tasks.some(task => task.state === 'RUNNING'))
|
|
20
|
+
return 'RUNNING';
|
|
21
|
+
if (job.tasks.some(task => task.state === 'QUEUED'))
|
|
22
|
+
return 'QUEUED';
|
|
23
|
+
if (job.tasks.some(task => task.state === 'BLOCKED'))
|
|
24
|
+
return 'BLOCKED';
|
|
25
|
+
if (job.tasks.some(task => task.state === 'FAILED'))
|
|
26
|
+
return 'FAILED';
|
|
27
|
+
if (job.tasks.some(task => task.state === 'DRAFT'))
|
|
28
|
+
return 'WAITING';
|
|
29
|
+
return 'WAITING';
|
|
30
|
+
}
|
|
31
|
+
const reviewTasks = job.tasks.filter(task => task.agentName === 'reviewer');
|
|
32
|
+
const reviewActive = reviewTasks.some(task => ['DRAFT', 'QUEUED', 'RUNNING'].includes(task.state))
|
|
33
|
+
|| job.review?.outcome === 'pending'
|
|
34
|
+
|| job.review?.outcome === 'started';
|
|
35
|
+
if (job.review?.outcome === 'approved' && prState === 'OPEN')
|
|
36
|
+
return 'MERGE_PENDING';
|
|
37
|
+
if (reviewActive || (job.reviewEnabled !== false && implementationComplete(job) && job.review?.outcome !== 'approved'))
|
|
38
|
+
return 'REVIEWING';
|
|
39
|
+
if (job.tasks.some(task => task.state === 'RUNNING'))
|
|
40
|
+
return 'RUNNING';
|
|
41
|
+
if (job.tasks.some(task => task.state === 'QUEUED'))
|
|
42
|
+
return 'QUEUED';
|
|
43
|
+
if (job.tasks.some(task => task.state === 'BLOCKED'))
|
|
44
|
+
return 'BLOCKED';
|
|
45
|
+
if (job.tasks.some(task => task.state === 'FAILED'))
|
|
46
|
+
return 'FAILED';
|
|
47
|
+
if (job.tasks.some(task => task.state === 'DRAFT'))
|
|
48
|
+
return 'WAITING';
|
|
49
|
+
if (job.reviewEnabled === false && implementationComplete(job) && prState === 'OPEN')
|
|
50
|
+
return 'MERGE_PENDING';
|
|
51
|
+
return 'IDLE';
|
|
52
|
+
}
|
package/dist/scheduled-jobs.js
CHANGED
|
@@ -147,7 +147,9 @@ export async function runScheduledBucket(client, repository, trustedAuthors, ref
|
|
|
147
147
|
await syncScheduleLabels(client, repository, job);
|
|
148
148
|
return { job, status: 'skipped-idempotent' };
|
|
149
149
|
}
|
|
150
|
-
|
|
150
|
+
if (job.state !== 'ACTIVE')
|
|
151
|
+
return { job, status: 'skipped-idempotent' };
|
|
152
|
+
const active = job.tasks.some(task => ['QUEUED', 'RUNNING'].includes(task.state));
|
|
151
153
|
if (active && schedule.concurrencyPolicy === 'skip') {
|
|
152
154
|
schedule.lastScheduledSlot = slot.toISOString();
|
|
153
155
|
schedule.nextRunAt = nextScheduleRun(schedule, now).toISOString();
|
|
@@ -166,7 +168,6 @@ export async function runScheduledBucket(client, repository, trustedAuthors, ref
|
|
|
166
168
|
schedule.nextRunAt = nextScheduleRun(schedule, missedRunPolicy === 'run' ? slot : now).toISOString();
|
|
167
169
|
job.updatedAt = now.toISOString();
|
|
168
170
|
const schedulerStore = new GitHubStore(client, repository, ref.prNumber, trustedAuthors);
|
|
169
|
-
job.state = 'RUNNING';
|
|
170
171
|
await schedulerStore.saveJob(job);
|
|
171
172
|
job = await schedulerStore.load(job.id);
|
|
172
173
|
await syncScheduleLabels(client, repository, job);
|
package/dist/scheduler.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { assertTaskTransition } from './types.js';
|
|
2
2
|
export function dependencyReady(task, tasks) { return task.dependencies.every(id => tasks.get(id)?.state === 'SUCCEEDED'); }
|
|
3
3
|
export function detectCycles(tasks) { const map = new Map(tasks.map(t => [t.id, t])); const visiting = new Set(); const done = new Set(); const visit = (id) => { if (visiting.has(id))
|
|
4
4
|
throw new Error(`Dependency cycle at ${id}`); if (done.has(id))
|
|
@@ -22,26 +22,17 @@ export function completionGateSatisfied(job) {
|
|
|
22
22
|
export function schedule(job) {
|
|
23
23
|
detectCycles(job.tasks);
|
|
24
24
|
const scopedTasks = scheduledCycleTasks(job);
|
|
25
|
-
if (scopedTasks.length === 0) {
|
|
26
|
-
if (job.executionMode === 'autonomous' && job.state !== 'WAITING') {
|
|
27
|
-
assertJobTransition(job.state, 'WAITING');
|
|
28
|
-
job.state = 'WAITING';
|
|
29
|
-
job.updatedAt = new Date().toISOString();
|
|
30
|
-
}
|
|
31
|
-
return job;
|
|
32
|
-
}
|
|
33
25
|
const map = new Map(job.tasks.map(t => [t.id, t]));
|
|
34
26
|
for (const task of scopedTasks) {
|
|
35
|
-
|
|
27
|
+
const ready = dependencyReady(task, map);
|
|
28
|
+
if (task.state === 'DRAFT' && task.dependencies.length > 0 && ready)
|
|
29
|
+
transitionTask(task, 'QUEUED');
|
|
30
|
+
else if (task.state === 'QUEUED' && !ready)
|
|
31
|
+
transitionTask(task, 'DRAFT');
|
|
32
|
+
else if (task.state === 'QUEUED' && ready && task.error !== null)
|
|
36
33
|
transitionTask(task, 'QUEUED');
|
|
37
34
|
}
|
|
38
|
-
|
|
39
|
-
const blocked = scopedTasks.some(t => t.state === 'BLOCKED');
|
|
40
|
-
const waiting = scopedTasks.some(t => t.state === 'WAITING');
|
|
41
|
-
const failed = scopedTasks.some(t => t.state === 'FAILED');
|
|
42
|
-
const cancelled = scopedTasks.every(t => t.state === 'CANCELLED');
|
|
43
|
-
const next = active ? 'RUNNING' : failed ? 'FAILED' : blocked ? 'BLOCKED' : waiting ? 'WAITING' : cancelled ? 'CANCELLED' : completionGateSatisfied(job) ? 'COMPLETED' : 'WAITING';
|
|
44
|
-
if (job.schedule && next === 'WAITING') {
|
|
35
|
+
if (job.schedule) {
|
|
45
36
|
const planners = scopedTasks.filter(task => task.kind === 'planner');
|
|
46
37
|
const latest = planners.sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id)).at(-1);
|
|
47
38
|
if (latest?.state === 'SUCCEEDED' && latest.plannerResult?.objective_status === 'satisfied') {
|
|
@@ -50,13 +41,9 @@ export function schedule(job) {
|
|
|
50
41
|
if (Number.isFinite(slot) && (!Number.isFinite(lastRun) || lastRun < slot))
|
|
51
42
|
job.schedule.lastRunAt = new Date().toISOString();
|
|
52
43
|
job.schedule.currentRunSlot = null;
|
|
44
|
+
job.updatedAt = new Date().toISOString();
|
|
53
45
|
}
|
|
54
46
|
}
|
|
55
|
-
if (job.state !== next) {
|
|
56
|
-
assertJobTransition(job.state, next);
|
|
57
|
-
job.state = next;
|
|
58
|
-
job.updatedAt = new Date().toISOString();
|
|
59
|
-
}
|
|
60
47
|
return job;
|
|
61
48
|
}
|
|
62
49
|
export function extendRetryBudget(task, windowSize = 3) {
|
package/dist/store.js
CHANGED
|
@@ -65,6 +65,7 @@ export class InMemoryStore {
|
|
|
65
65
|
this.values.set(key, renderTaskComment(task));
|
|
66
66
|
}
|
|
67
67
|
async saveTask(task) { this.values.set(`${TASK_MARKER}:${task.jobId ?? ''}:${task.id}`, renderTaskComment(task)); }
|
|
68
|
+
async closePullRequest() { }
|
|
68
69
|
async headSha() { const jobs = [...this.values.values()].map(parseJob).filter((x) => x !== null); return jobs[0]?.headSha ?? jobs[0]?.review?.headSha ?? null; }
|
|
69
70
|
async listAgents() { return [...this.values.values()].map(parseAgent).filter((x) => x !== null); }
|
|
70
71
|
async saveAgent(agent) { this.values.set(`${AGENT_MARKER}:${agent.id}`, renderAgentComment(agent)); }
|
|
@@ -473,6 +474,9 @@ export class GitHubStore {
|
|
|
473
474
|
if (result.merged !== true)
|
|
474
475
|
throw new Error(typeof result.message === 'string' ? result.message : 'Pull request #' + this.prNumber + ' was not merged');
|
|
475
476
|
}
|
|
477
|
+
async closePullRequest() {
|
|
478
|
+
await this.client.request('repos/' + this.repository + '/pulls/' + this.prNumber, { method: 'PATCH', body: JSON.stringify({ state: 'closed' }) });
|
|
479
|
+
}
|
|
476
480
|
async headSha() {
|
|
477
481
|
const { owner, name } = this.repoParts();
|
|
478
482
|
const data = await this.graphql('query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){pullRequest(number:$number){headRefOid}}}', { owner, name, number: this.prNumber });
|
package/dist/types.js
CHANGED
|
@@ -2,9 +2,9 @@ export const STORY_SIZE_TIMEOUT_MS = { S: 300000, M: 900000, L: 2700000 };
|
|
|
2
2
|
export function storySizeTimeoutMs(size) { return STORY_SIZE_TIMEOUT_MS[size]; }
|
|
3
3
|
export function taskOverallTimeoutMs(size, reasoning) { return storySizeTimeoutMs(size) * (reasoning?.toLowerCase() === 'high' ? 2 : 1); }
|
|
4
4
|
export function chatGptStartAckTimeoutMs(overallTimeoutMs) { return Math.min(600000, Math.max(120000, Math.floor(overallTimeoutMs / 3))); }
|
|
5
|
-
const
|
|
6
|
-
const taskTransitions = {
|
|
7
|
-
export function
|
|
5
|
+
const jobLifecycleTransitions = { DRAFT: ['ACTIVE', 'COMPLETED', 'CANCELLED'], ACTIVE: ['COMPLETED', 'CANCELLED'], COMPLETED: [], CANCELLED: ['COMPLETED'] };
|
|
6
|
+
const taskTransitions = { DRAFT: ['QUEUED', 'BLOCKED', 'CANCELLED'], QUEUED: ['DRAFT', 'RUNNING', 'BLOCKED', 'FAILED', 'CANCELLED'], RUNNING: ['BLOCKED', 'SUCCEEDED', 'FAILED', 'CANCELLED', 'QUEUED'], BLOCKED: ['QUEUED', 'CANCELLED'], SUCCEEDED: [], FAILED: ['QUEUED', 'RUNNING', 'CANCELLED'], CANCELLED: [] };
|
|
7
|
+
export function assertJobLifecycleTransition(from, to) { if (from !== to && !jobLifecycleTransitions[from].includes(to))
|
|
8
8
|
throw new Error(`Invalid job transition ${from} -> ${to}`); }
|
|
9
9
|
export function assertTaskTransition(from, to) { if (from !== to && !taskTransitions[from].includes(to))
|
|
10
10
|
throw new Error(`Invalid task transition ${from} -> ${to}`); }
|
package/package.json
CHANGED
|
@@ -30,10 +30,33 @@ Relay derives the overall execution timeout from `storySize`. If `routing.reason
|
|
|
30
30
|
|
|
31
31
|
Raw `timeoutMs` is a compatibility/debug/hotfix override, not the normal task-production interface.
|
|
32
32
|
|
|
33
|
+
## Task readiness states
|
|
34
|
+
|
|
35
|
+
Task readiness is explicit and must use these semantics:
|
|
36
|
+
|
|
37
|
+
- `DRAFT`: durable Task exists but is not executable now. A DRAFT Task with dependencies is promoted to `QUEUED` when all dependencies succeed. A DRAFT Task with no dependencies remains held until an explicit release to `QUEUED`.
|
|
38
|
+
- `QUEUED`: executable now; Relay may launch it immediately subject only to worker capacity/routing.
|
|
39
|
+
- `RUNNING`: worker execution has actually started.
|
|
40
|
+
- `BLOCKED`: execution cannot continue without an external resolution or intervention. Once a Task has entered `RUNNING`, it must not return to `DRAFT`; waiting discovered during execution is `BLOCKED`.
|
|
41
|
+
- `SUCCEEDED`, `FAILED`, and `CANCELLED` are terminal outcomes, except that retry explicitly requeues a failed/blocked Task.
|
|
42
|
+
|
|
43
|
+
Do not use `WAITING` as a Task state or durable Job lifecycle state. `WAITING` is a derived Job runtime status for an `ACTIVE` Job whose next progress depends on a future trigger, schedule slot, dependency release, or other condition.
|
|
44
|
+
|
|
33
45
|
## Worker lifecycle
|
|
34
46
|
|
|
35
47
|
Relay owns process lifecycle. Workers own semantic task lifecycle. A worker must publish canonical `task.started` as its first execution action, then exactly one of `task.completed`, `task.failed`, `task.blocked`, or `task.cancelled`. Process launch/exit events do not substitute for worker semantic events.
|
|
36
48
|
|
|
49
|
+
## Job lifecycle and runtime status
|
|
50
|
+
|
|
51
|
+
Durable Job lifecycle is intentionally small: `DRAFT | ACTIVE | COMPLETED | CANCELLED`.
|
|
52
|
+
|
|
53
|
+
- `DRAFT`: durable objective exists, but the execution engine must not run it.
|
|
54
|
+
- `ACTIVE`: the execution engine may reconcile the Job. Scheduler, external triggers, manual actions, dependency release, workers, and review may all advance an ACTIVE Job.
|
|
55
|
+
- `COMPLETED`: terminal successful lifecycle. `job merge` merges the backing PR; a merged PR is authoritative and reconciles the Job to COMPLETED.
|
|
56
|
+
- `CANCELLED`: terminal cancellation. `job cancel` closes the backing PR; a closed, unmerged managed PR also reconciles the Job to CANCELLED.
|
|
57
|
+
|
|
58
|
+
Job runtime status is derived, not persisted. Relay computes it from lifecycle plus Task, Review, PR, and schedule facts. Runtime values include `IDLE`, `WAITING`, `QUEUED`, `RUNNING`, `BLOCKED`, `FAILED`, `REVIEWING`, `MERGE_PENDING`, `MERGED`, and `CANCELLED`. A blocked Task does not by itself make the Job runtime BLOCKED when other runnable/running work can still advance the objective. Lifecycle is the execution gate; runtime status is observation.
|
|
59
|
+
|
|
37
60
|
## Job and review lifecycle
|
|
38
61
|
|
|
39
62
|
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.
|
|
@@ -44,7 +67,7 @@ Final-review routing follows the same worker-selection boundary as executable wo
|
|
|
44
67
|
|
|
45
68
|
The public CLI resource surface is:
|
|
46
69
|
|
|
47
|
-
- `job create|update|show|list`
|
|
70
|
+
- `job create|update|show|list|merge|cancel`
|
|
48
71
|
- `task create|update|show|list|retry|cancel`
|
|
49
72
|
|
|
50
73
|
`job create` persists only durable Job state. It never creates executable work. `task create` creates a real executable Task directly under the Job; direct orchestrator-created Tasks are not wrapped in synthetic planner Tasks and have no planner-round provenance.
|
|
@@ -55,4 +78,4 @@ A zero-task autonomous Job stays idle. An autonomous Job may begin with one or m
|
|
|
55
78
|
|
|
56
79
|
Planner-produced Tasks obey this same contract, including explicit `storySize`.
|
|
57
80
|
|
|
58
|
-
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,
|
|
81
|
+
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, 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.
|