agents-relay 1.0.97 → 1.0.99
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 +1 -1
- package/dist/dashboard.js +0 -6
- package/dist/reconciler-runtime.js +14 -14
- package/dist/reconciler.js +34 -20
- package/dist/scheduler.js +1 -1
- package/dist/store-github.js +7 -7
- package/dist/troubleshooter.js +4 -3
- package/dist/workflow/lifecycle.js +8 -0
- package/dist/workflow/task-reconcile.js +1 -1
- package/dist/workspace.js +36 -2
- package/package.json +2 -2
- package/skills/agents-relay/agents/planner.agent.md +56 -2
- package/skills/agents-relay/contracts/task-job-contract.md +4 -0
- package/dist/workflow/rollout.js +0 -185
package/README.md
CHANGED
|
@@ -51,7 +51,7 @@ Jobs separate durable lifecycle from derived runtime status. Durable lifecycle i
|
|
|
51
51
|
|
|
52
52
|
Lifecycle transition authority lives in the directly renderable canonical sources [`workflow/job-lifecycle.mmd`](workflow/job-lifecycle.mmd) and [`workflow/task-lifecycle.mmd`](workflow/task-lifecycle.mmd); see [`docs/workflow-engine.md`](docs/workflow-engine.md) for the executable Mermaid profile and runtime boundary.
|
|
53
53
|
|
|
54
|
-
Graph
|
|
54
|
+
The canonical Mermaid Graph Engine is the single authoritative Job/Task lifecycle decision path. Lifecycle events, guards, deterministic mutations, and declarative effects are defined by the workflow graphs and executed through the shared graph runtime.
|
|
55
55
|
|
|
56
56
|
Job objectives and Task inputs are Markdown. Prefer `--objective-file` / `--input-file` (use `-` for stdin) for durable long-form context; inline `--objective` / `--input` remain for short/simple text and are mutually exclusive with the file variants. When a diagram materially clarifies the work, put Mermaid first and then Markdown detail. A newly created Job must capture enough context to finish independently of the originating chat. Its PR body contains exactly one hidden `agents-relay:job:v1` metadata marker followed by the objective Markdown; there is no separate Job comment duplicating the objective. Objective and Job metadata updates rewrite that same PR body. Legacy Job-comment markers remain readable and are migrated into the PR body on the next Job save.
|
|
57
57
|
|
package/dist/dashboard.js
CHANGED
|
@@ -5,7 +5,6 @@ import { join } from 'node:path';
|
|
|
5
5
|
import { readUsageSnapshot } from './usage.js';
|
|
6
6
|
import { deriveJobRuntimeStatus } from './runtime-status.js';
|
|
7
7
|
import { DeliveryDeduper, parseGitHubWebhook, verifyGitHubWebhook } from './github-webhook.js';
|
|
8
|
-
import { workflowRolloutSnapshot } from './workflow/rollout.js';
|
|
9
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})$/;
|
|
10
9
|
const SSE_HEARTBEAT_MS = 15000;
|
|
11
10
|
const EVENTS_HISTORY_FILE = process.env.EVENTS_BUS_DATA_DIR ? join(process.env.EVENTS_BUS_DATA_DIR, 'events.jsonl') : join(homedir(), 'Library', 'Application Support', 'EventsBus', 'events.jsonl');
|
|
@@ -268,11 +267,6 @@ export function serveDashboard(store, port = 8787, bus, refreshMs = 300000, jobI
|
|
|
268
267
|
res.end(JSON.stringify(events));
|
|
269
268
|
return;
|
|
270
269
|
}
|
|
271
|
-
if (req.url === '/api/workflow-rollout') {
|
|
272
|
-
res.writeHead(200, { 'content-type': 'application/json' });
|
|
273
|
-
res.end(JSON.stringify(workflowRolloutSnapshot()));
|
|
274
|
-
return;
|
|
275
|
-
}
|
|
276
270
|
if (req.url === '/api/status') {
|
|
277
271
|
const status = statusProvider ? statusProvider() : { limited: false, cooldownUntil: null };
|
|
278
272
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
@@ -165,13 +165,14 @@ export class ReconcilerRuntimeBase {
|
|
|
165
165
|
this.detach(this.reconcile(job.id), job.id, task.id, 'late worker-start reconciliation');
|
|
166
166
|
}
|
|
167
167
|
isRecoverableWatchdogFailure(task) {
|
|
168
|
-
if (['worker_start_timeout', 'terminal_event_timeout', 'worker_exit_before_start', 'worker_exit_without_terminal_event'].includes(task.failureReason ?? ''))
|
|
168
|
+
if (['worker_start_timeout', 'terminal_event_timeout', 'worker_exit_before_start', 'worker_exit_without_terminal_event', 'worker_delivery_after_start'].includes(task.failureReason ?? ''))
|
|
169
169
|
return true;
|
|
170
170
|
const error = task.error ?? '';
|
|
171
171
|
return error.includes('worker_start_timeout')
|
|
172
172
|
|| error.includes('timed out waiting for the mandatory terminal worker event')
|
|
173
173
|
|| error.includes('exited before publishing task.started')
|
|
174
|
-
|| error.includes('exited after task.started without publishing a terminal task event')
|
|
174
|
+
|| error.includes('exited after task.started without publishing a terminal task event')
|
|
175
|
+
|| error.includes('Worker delivery failed after task.started');
|
|
175
176
|
}
|
|
176
177
|
isCorrelatedWorkerEvent(task, event) {
|
|
177
178
|
return task.executionId !== null && eventSourceId(event) === executionEventSource(event.job_id, task.id, task.executionId);
|
|
@@ -187,19 +188,18 @@ export class ReconcilerRuntimeBase {
|
|
|
187
188
|
const task = job.tasks.find(item => item.id === taskId);
|
|
188
189
|
if (!task || task.executionId !== executionId || !['QUEUED', 'RUNNING'].includes(task.state))
|
|
189
190
|
return;
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
task.error = event.message;
|
|
194
|
-
task.failureReason = phase === 'start-ack' ? 'worker_start_timeout' : undefined;
|
|
195
|
-
task.leaseOwner = null;
|
|
196
|
-
task.leaseExpiresAt = null;
|
|
197
|
-
await transitionTaskEvent(task, 'fail');
|
|
198
|
-
await this.store.saveTask(task);
|
|
199
|
-
}
|
|
200
|
-
else {
|
|
201
|
-
await this.applyTerminalEvent(event, true);
|
|
191
|
+
if (task.state === 'RUNNING') {
|
|
192
|
+
await this.emit(eventFor(jobId, taskId, task.parentTaskId, 'task.process.delivery_failed', 'failed', `Worker ${phase} reported after task.started: ${message}`, 'orchestrator', { phase, authoritative: false, executionId }, { agent: 'relay', component: 'agents-relay' }));
|
|
193
|
+
return;
|
|
202
194
|
}
|
|
195
|
+
const event = eventFor(jobId, taskId, task.parentTaskId, 'task.failed', 'failed', `Worker ${phase} failed: ${message}`, 'user', { phase, reason: phase === 'start-ack' ? 'worker_start_timeout' : undefined }, { agent: 'relay', component: 'agents-relay', watchdog: true });
|
|
196
|
+
this.clearExecution(task.id, executionId);
|
|
197
|
+
task.error = event.message;
|
|
198
|
+
task.failureReason = phase === 'start-ack' ? 'worker_start_timeout' : undefined;
|
|
199
|
+
task.leaseOwner = null;
|
|
200
|
+
task.leaseExpiresAt = null;
|
|
201
|
+
await transitionTaskEvent(task, 'fail');
|
|
202
|
+
await this.store.saveTask(task);
|
|
203
203
|
await this.emit(event);
|
|
204
204
|
}
|
|
205
205
|
async runtimeSettled(jobId, taskId, executionId, result, error) {
|
package/dist/reconciler.js
CHANGED
|
@@ -12,6 +12,7 @@ import { executionEventSource } from './reconciler-runtime.js';
|
|
|
12
12
|
export { executionEventSource } from './reconciler-runtime.js';
|
|
13
13
|
import { CallbackWorkflowEffectHandler } from './workflow/effects.js';
|
|
14
14
|
import { pendingTroubleshooterTasks } from './troubleshooter.js';
|
|
15
|
+
import { prepareManagedExecutionWorktree } from './workspace.js';
|
|
15
16
|
const PRIORITY_RANK = { P0: 0, P1: 1, P2: 2, P3: 3 };
|
|
16
17
|
export function compareTaskPriority(a, b, jobPriority = 'P2') { const ar = PRIORITY_RANK[a.priority ?? jobPriority]; const br = PRIORITY_RANK[b.priority ?? jobPriority]; return ar - br || a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id); }
|
|
17
18
|
export async function managedWorkerInput(job, task, context) {
|
|
@@ -203,7 +204,7 @@ export class Reconciler extends ReconcilerReviewBase {
|
|
|
203
204
|
for (const task of job.tasks.filter(item => !['SUCCEEDED', 'FAILED', 'CANCELLED'].includes(item.state))) {
|
|
204
205
|
this.live.get(task.id)?.execution.cancel();
|
|
205
206
|
this.plannerLive.get(task.id)?.controller.abort();
|
|
206
|
-
await transitionTaskEvent(task, 'cancel');
|
|
207
|
+
await transitionTaskEvent(task, task.state === 'RUNNING' ? 'task.cancelled' : 'cancel');
|
|
207
208
|
task.error = reason;
|
|
208
209
|
task.leaseOwner = null;
|
|
209
210
|
task.leaseExpiresAt = null;
|
|
@@ -214,8 +215,7 @@ export class Reconciler extends ReconcilerReviewBase {
|
|
|
214
215
|
async recoverLostExecutions(job, now) {
|
|
215
216
|
for (const task of job.tasks.filter(item => item.state === 'RUNNING' || (item.state === 'QUEUED' && item.executionId !== null))) {
|
|
216
217
|
const expired = task.leaseExpiresAt !== null && new Date(task.leaseExpiresAt).getTime() <= now;
|
|
217
|
-
|
|
218
|
-
if (!expired && !ownerLost)
|
|
218
|
+
if (!expired)
|
|
219
219
|
continue;
|
|
220
220
|
this.live.get(task.id)?.execution.cancel();
|
|
221
221
|
const planner = this.plannerLive.get(task.id);
|
|
@@ -226,7 +226,7 @@ export class Reconciler extends ReconcilerReviewBase {
|
|
|
226
226
|
}
|
|
227
227
|
const failedEvent = task.state === 'RUNNING' ? 'task.failed' : 'fail';
|
|
228
228
|
await transitionTaskEvent(task, failedEvent);
|
|
229
|
-
task.error =
|
|
229
|
+
task.error = 'Lease expired';
|
|
230
230
|
task.leaseOwner = null;
|
|
231
231
|
task.leaseExpiresAt = null;
|
|
232
232
|
task.executionId = null;
|
|
@@ -237,7 +237,7 @@ export class Reconciler extends ReconcilerReviewBase {
|
|
|
237
237
|
else {
|
|
238
238
|
task.error += ' after maximum attempts';
|
|
239
239
|
await this.store.saveTask(task);
|
|
240
|
-
await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'task.failed', 'failed', task.error, 'user', { reason:
|
|
240
|
+
await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'task.failed', 'failed', task.error, 'user', { reason: 'lease_expired' }));
|
|
241
241
|
}
|
|
242
242
|
}
|
|
243
243
|
}
|
|
@@ -294,8 +294,8 @@ export class Reconciler extends ReconcilerReviewBase {
|
|
|
294
294
|
}
|
|
295
295
|
}
|
|
296
296
|
}
|
|
297
|
-
async resolvedContext(job, task, role) {
|
|
298
|
-
const projectPath = this.options.projectPathForJob ? await this.options.projectPathForJob(job) : task.routing?.cwd ?? process.cwd();
|
|
297
|
+
async resolvedContext(job, task, role, projectPathOverride) {
|
|
298
|
+
const projectPath = projectPathOverride ?? (this.options.projectPathForJob ? await this.options.projectPathForJob(job) : task.routing?.cwd ?? process.cwd());
|
|
299
299
|
return resolveExecutionContext(projectPath, role, {
|
|
300
300
|
...(this.options.executionContextRoots ?? {}),
|
|
301
301
|
registeredAgents: await this.store.listAgents(),
|
|
@@ -316,17 +316,6 @@ export class Reconciler extends ReconcilerReviewBase {
|
|
|
316
316
|
await this.store.saveTask(task);
|
|
317
317
|
return;
|
|
318
318
|
}
|
|
319
|
-
let executionContext;
|
|
320
|
-
try {
|
|
321
|
-
const role = task.agentName === 'reviewer' ? 'reviewer' : task.agentName === 'troubleshooter' ? 'troubleshooter' : task.parentTaskId ? 'nested-worker' : 'worker';
|
|
322
|
-
executionContext = await this.resolvedContext(job, task, role);
|
|
323
|
-
}
|
|
324
|
-
catch (error) {
|
|
325
|
-
await transitionTaskEvent(task, 'block');
|
|
326
|
-
task.error = error instanceof Error ? error.message : String(error);
|
|
327
|
-
await this.store.saveTask(task);
|
|
328
|
-
return;
|
|
329
|
-
}
|
|
330
319
|
const claimKey = `${job.repository}#${job.prNumber}:${task.id}`;
|
|
331
320
|
if (taskLaunchClaims.has(claimKey))
|
|
332
321
|
return;
|
|
@@ -354,13 +343,38 @@ export class Reconciler extends ReconcilerReviewBase {
|
|
|
354
343
|
if (!claimedTask)
|
|
355
344
|
return;
|
|
356
345
|
task = claimedTask;
|
|
346
|
+
let executionContext;
|
|
347
|
+
let managedHead = null;
|
|
348
|
+
try {
|
|
349
|
+
const role = task.agentName === 'reviewer' ? 'reviewer' : task.agentName === 'troubleshooter' ? 'troubleshooter' : task.parentTaskId ? 'nested-worker' : 'worker';
|
|
350
|
+
let projectPath = this.options.projectPathForJob ? await this.options.projectPathForJob(job) : task.routing?.cwd ?? process.cwd();
|
|
351
|
+
if (['codex', 'chatgpt'].includes(task.adapter) && this.store.pullRequestHead && task.executionId) {
|
|
352
|
+
managedHead = await this.store.pullRequestHead();
|
|
353
|
+
if (!managedHead)
|
|
354
|
+
throw new Error(`PR #${job.prNumber} has no resolvable head ref`);
|
|
355
|
+
projectPath = await prepareManagedExecutionWorktree(projectPath, job.repository, job.prNumber, managedHead, task.executionId);
|
|
356
|
+
}
|
|
357
|
+
executionContext = await this.resolvedContext(job, task, role, projectPath);
|
|
358
|
+
}
|
|
359
|
+
catch (error) {
|
|
360
|
+
task.leaseOwner = null;
|
|
361
|
+
task.leaseExpiresAt = null;
|
|
362
|
+
task.executionId = null;
|
|
363
|
+
await transitionTaskEvent(task, 'block');
|
|
364
|
+
task.error = `Execution checkout unavailable: ${error instanceof Error ? error.message : String(error)}`;
|
|
365
|
+
await this.store.saveTask(task);
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
357
368
|
const startedAt = Date.now();
|
|
358
369
|
const durableExecutionId = task.executionId;
|
|
359
370
|
const controller = new AbortController();
|
|
360
371
|
let execution;
|
|
361
372
|
const sourceId = executionEventSource(job.id, task.id, durableExecutionId);
|
|
362
|
-
const
|
|
363
|
-
const
|
|
373
|
+
const baseWorkerInput = await managedWorkerInput(job, task, executionContext);
|
|
374
|
+
const workerInput = managedHead
|
|
375
|
+
? `${baseWorkerInput}\n\n[Managed PR checkout]\nExecution cwd: ${executionContext.project.path}\nExpected PR head: ${managedHead.ref} @ ${managedHead.sha}\nThis checkout is isolated from the user's primary checkout. Commit only here. Push durable changes with: git push origin HEAD:${managedHead.ref}\nDo not switch, reset, merge into, or commit on the primary checkout.`
|
|
376
|
+
: baseWorkerInput;
|
|
377
|
+
const workerTask = { ...task, input: workerInput, routing: task.routing ? { ...task.routing, cwd: executionContext.project.path } : task.routing };
|
|
364
378
|
const startAckSetup = task.adapter === 'chatgpt' ? await this.startAck(job.id, task.id, sourceId, Date.now(), this.options.startAckTimeoutMs ?? chatGptStartAckTimeoutMs(task.timeoutMs)) : null;
|
|
365
379
|
const closePolicy = task.chatgptTabClosePolicy ?? this.options.chatgptTabClosePolicy ?? adapter.tabClosePolicy ?? 'after-start';
|
|
366
380
|
const terminalWait = task.adapter === 'chatgpt' && closePolicy === 'after-terminal'
|
package/dist/scheduler.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { decideJobLifecycle, decideTaskLifecycle } from './workflow/
|
|
1
|
+
import { decideJobLifecycle, decideTaskLifecycle } from './workflow/lifecycle.js';
|
|
2
2
|
import { reconcileTaskDependencies } from './workflow/task-reconcile.js';
|
|
3
3
|
export { dependencyReady, detectCycles } from './workflow/task-reconcile.js';
|
|
4
4
|
import { applyJobWorkflowDecision, applyTaskWorkflowDecision } from './workflow/runtime.js';
|
package/dist/store-github.js
CHANGED
|
@@ -25,7 +25,7 @@ export class GitHubStore {
|
|
|
25
25
|
}
|
|
26
26
|
async prNode() {
|
|
27
27
|
const { owner, name } = this.repoParts();
|
|
28
|
-
const data = await this.graphql('query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){pullRequest(number:$number){id state merged mergedAt closedAt body}}}', { owner, name, number: this.prNumber });
|
|
28
|
+
const data = await this.graphql('query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){pullRequest(number:$number){id state merged mergedAt closedAt body headRefName headRefOid}}}', { owner, name, number: this.prNumber });
|
|
29
29
|
const repository = data.repository;
|
|
30
30
|
const pr = repository?.pullRequest;
|
|
31
31
|
if (!pr)
|
|
@@ -86,13 +86,13 @@ export class GitHubStore {
|
|
|
86
86
|
async updatePullRequestBody(body) {
|
|
87
87
|
await this.client.request('repos/' + this.repository + '/pulls/' + this.prNumber, { method: 'PATCH', body: JSON.stringify({ body }) });
|
|
88
88
|
}
|
|
89
|
-
async
|
|
90
|
-
const
|
|
91
|
-
const
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
return typeof pr?.headRefOid === 'string' ? pr.headRefOid : null;
|
|
89
|
+
async pullRequestHead() {
|
|
90
|
+
const pr = await this.prNode();
|
|
91
|
+
const ref = typeof pr.headRefName === 'string' ? pr.headRefName : '';
|
|
92
|
+
const sha = typeof pr.headRefOid === 'string' ? pr.headRefOid : '';
|
|
93
|
+
return ref && sha ? { ref, sha } : null;
|
|
95
94
|
}
|
|
95
|
+
async headSha() { return (await this.pullRequestHead())?.sha ?? null; }
|
|
96
96
|
async load(jobId) {
|
|
97
97
|
const comments = (await this.list()).filter(comment => this.isTrusted(comment));
|
|
98
98
|
const bodyJob = parseJob(await this.pullRequestBody());
|
package/dist/troubleshooter.js
CHANGED
|
@@ -38,8 +38,9 @@ function recoveryTargetsTask(recovery, original) {
|
|
|
38
38
|
return recovery.parentTaskId === original.id;
|
|
39
39
|
return recovery.id.startsWith(`troubleshoot-${slug(original.id)}-a`);
|
|
40
40
|
}
|
|
41
|
-
function
|
|
42
|
-
return job.tasks.some(task => recoveryTargetsTask(task, original)
|
|
41
|
+
function hasRecoveryBarrier(job, original) {
|
|
42
|
+
return job.tasks.some(task => recoveryTargetsTask(task, original)
|
|
43
|
+
&& (ACTIVE_RECOVERY_STATES.has(task.state) || ['FAILED', 'CANCELLED'].includes(task.state)));
|
|
43
44
|
}
|
|
44
45
|
export function pendingTroubleshooterTasks(job) {
|
|
45
46
|
const existing = new Set(job.tasks.filter(task => task.agentName === 'troubleshooter').map(task => task.id));
|
|
@@ -48,7 +49,7 @@ export function pendingTroubleshooterTasks(job) {
|
|
|
48
49
|
if (!['FAILED', 'BLOCKED'].includes(task.state) || task.kind === 'planner' || task.agentName === 'troubleshooter' || !belongsToCurrentRun(job, task))
|
|
49
50
|
continue;
|
|
50
51
|
const id = troubleshooterTaskId(task);
|
|
51
|
-
if (existing.has(id) ||
|
|
52
|
+
if (existing.has(id) || hasRecoveryBarrier(job, task))
|
|
52
53
|
continue;
|
|
53
54
|
const now = new Date().toISOString();
|
|
54
55
|
recoveries.push({
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { jobLifecycleWorkflow, taskLifecycleWorkflow } from './definitions.js';
|
|
2
|
+
import { graphWorkflowDecision } from './runtime.js';
|
|
3
|
+
export function decideTaskLifecycle(task, event, facts = {}) {
|
|
4
|
+
return graphWorkflowDecision(taskLifecycleWorkflow, task.state, event, facts);
|
|
5
|
+
}
|
|
6
|
+
export function decideJobLifecycle(job, event, facts = {}) {
|
|
7
|
+
return graphWorkflowDecision(jobLifecycleWorkflow, job.state, event, facts);
|
|
8
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { decideTaskLifecycle } from './
|
|
1
|
+
import { decideTaskLifecycle } from './lifecycle.js';
|
|
2
2
|
import { applyTaskWorkflowDecision } from './runtime.js';
|
|
3
3
|
export function dependencyReady(task, tasks) {
|
|
4
4
|
return task.dependencies.every(id => tasks.get(id)?.state === 'SUCCEEDED');
|
package/dist/workspace.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { readdir, stat } from 'node:fs/promises';
|
|
2
|
-
import { join, relative, resolve } from 'node:path';
|
|
1
|
+
import { mkdir, readdir, stat } from 'node:fs/promises';
|
|
2
|
+
import { dirname, join, relative, resolve } from 'node:path';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { execFile } from 'node:child_process';
|
|
5
5
|
import { promisify } from 'node:util';
|
|
@@ -75,6 +75,40 @@ export async function resolveManagedProjectPath(repository, explicitProjectPath
|
|
|
75
75
|
return resolve(explicitProjectPath);
|
|
76
76
|
return repository ? resolveWorkspaceRepositoryPath(repository, root) : resolve(cwd);
|
|
77
77
|
}
|
|
78
|
+
async function git(path, args) {
|
|
79
|
+
return (await execFileAsync('git', ['-C', path, ...args])).stdout.trim();
|
|
80
|
+
}
|
|
81
|
+
export async function prepareManagedExecutionWorktree(projectPath, repository, prNumber, head, executionId) {
|
|
82
|
+
const primary = await git(projectPath, ['rev-parse', '--show-toplevel']);
|
|
83
|
+
const remote = await git(primary, ['config', '--get', 'remote.origin.url']);
|
|
84
|
+
if (githubRepositoryFromRemote(remote) !== repository)
|
|
85
|
+
throw new Error(`Primary checkout ${primary} does not match ${repository}`);
|
|
86
|
+
await git(primary, ['fetch', 'origin', `refs/heads/${head.ref}:refs/remotes/origin/${head.ref}`]);
|
|
87
|
+
const fetched = await git(primary, ['rev-parse', `refs/remotes/origin/${head.ref}`]);
|
|
88
|
+
if (fetched !== head.sha)
|
|
89
|
+
throw new Error(`PR #${prNumber} head changed while preparing execution checkout (${head.sha} -> ${fetched})`);
|
|
90
|
+
const safeRepo = repository.replace(/[^A-Za-z0-9_.-]+/g, '-');
|
|
91
|
+
const safeExecution = executionId.replace(/[^A-Za-z0-9_.-]+/g, '-');
|
|
92
|
+
const worktree = join(dirname(primary), '.worktrees', 'agents-relay', `${safeRepo}-pr-${prNumber}`, safeExecution);
|
|
93
|
+
await mkdir(dirname(worktree), { recursive: true });
|
|
94
|
+
try {
|
|
95
|
+
const existingRoot = await git(worktree, ['rev-parse', '--show-toplevel']);
|
|
96
|
+
const existingHead = await git(worktree, ['rev-parse', 'HEAD']);
|
|
97
|
+
if (resolve(existingRoot) !== resolve(worktree) || existingHead !== head.sha)
|
|
98
|
+
throw new Error(`Existing execution worktree ${worktree} is not the expected PR head`);
|
|
99
|
+
return worktree;
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
try {
|
|
103
|
+
await stat(worktree);
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
await git(primary, ['worktree', 'add', '--detach', worktree, head.sha]);
|
|
107
|
+
return worktree;
|
|
108
|
+
}
|
|
109
|
+
throw error;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
78
112
|
function repositoryPathRank(path, root) {
|
|
79
113
|
const relativePath = relative(root, path);
|
|
80
114
|
if (!relativePath.includes('/'))
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agents-relay",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.99",
|
|
4
4
|
"description": "Durable async agent jobs coordinated through GitHub pull requests",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
},
|
|
10
10
|
"scripts": {
|
|
11
11
|
"build": "tsc -p tsconfig.json && chmod +x dist/cli.js dist/relayd.js",
|
|
12
|
-
"test": "npm run build && node --test test/*.test.js",
|
|
12
|
+
"test": "npm run build && node --test --test-force-exit test/*.test.js",
|
|
13
13
|
"test:chatgpt-temp-quality": "AGENTS_RELAY_RUN_CHATGPT_TEMP_QUALITY=1 AGENTS_RELAY_CHATGPT_BROWSER_WORKER_AGENT=/Users/chengli/Workspace/neo/skills/chatgpt-browser-worker/agents/browser-worker.agent.md sh -c \"npm run build && node test/quality/run-temporary-chat.mjs\"",
|
|
14
14
|
"dev": "tsx src/cli.ts"
|
|
15
15
|
},
|
|
@@ -8,15 +8,62 @@ You are a pure planning agent. Relay invokes you with the durable Job, existing
|
|
|
8
8
|
|
|
9
9
|
Relay persists each successful planner invocation as one planner round and creates the returned Tasks under that round.
|
|
10
10
|
|
|
11
|
+
## Planning contract
|
|
12
|
+
|
|
13
|
+
**Plan the whole objective before choosing Task boundaries.**
|
|
14
|
+
|
|
15
|
+
For every non-satisfied objective:
|
|
16
|
+
|
|
17
|
+
1. Reconstruct the end-to-end execution flow from the current durable state to objective completion.
|
|
18
|
+
2. Express that flow as a compact Mermaid graph in `assessment`.
|
|
19
|
+
3. Mark the points in that graph that deserve durable Task boundaries.
|
|
20
|
+
4. Create only the next useful executable Task batch from those boundaries.
|
|
21
|
+
|
|
22
|
+
A Task is **not** just a workflow step. Create a Task when its outcome deserves an independent durable lifecycle because it may need to be:
|
|
23
|
+
|
|
24
|
+
- discussed or referenced later;
|
|
25
|
+
- independently reviewed, approved, revised, or compared;
|
|
26
|
+
- blocked/retried/reconciled independently;
|
|
27
|
+
- waited on as a meaningful external dependency;
|
|
28
|
+
- executed in parallel with a separately meaningful outcome; or
|
|
29
|
+
- preserved as an independently useful deliverable or decision.
|
|
30
|
+
|
|
31
|
+
If an intermediate result does not need that independent lifecycle, keep it inside the executing Task's agent graph/workflow rather than promoting it to another Task.
|
|
32
|
+
|
|
33
|
+
Keep sibling Tasks at a comparable abstraction level. Do not mix a whole product milestone with tiny implementation steps as peers.
|
|
34
|
+
|
|
35
|
+
The graph is the explanation for the decomposition: every emitted Task should map to a meaningful boundary in the graph, and important graph boundaries should not be omitted accidentally.
|
|
36
|
+
|
|
37
|
+
### Examples
|
|
38
|
+
|
|
39
|
+
Prefer:
|
|
40
|
+
|
|
41
|
+
```text
|
|
42
|
+
Task: Create and publish today's vlog
|
|
43
|
+
internal graph: collect media -> select story -> script -> edit -> captions -> QA -> publish
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
when nobody needs to separately review or discuss those intermediate outputs.
|
|
47
|
+
|
|
48
|
+
But when script approval is a real durable gate, prefer:
|
|
49
|
+
|
|
50
|
+
```text
|
|
51
|
+
Task 1: Produce script for review
|
|
52
|
+
-> approval/revision boundary
|
|
53
|
+
Task 2: Produce and publish the approved vlog
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Do not create separate Tasks merely for "analyze", "format", "call tool", "check status", or other internal mechanics.
|
|
57
|
+
|
|
11
58
|
## Rules
|
|
12
59
|
|
|
13
60
|
- Treat durable Job/Task state as authoritative.
|
|
14
61
|
- Never invent completed work or evidence.
|
|
15
62
|
- First decide whether the objective is already satisfied.
|
|
16
63
|
- If satisfied, return `objective_status: "satisfied"` and `next_tasks: []`.
|
|
17
|
-
- Otherwise return only the next useful executable batch.
|
|
64
|
+
- Otherwise return only the next useful executable batch from the graph-derived Task boundaries.
|
|
18
65
|
- Do not repeat completed work or recreate an existing Task unless replacement work is explicitly needed.
|
|
19
|
-
- Prefer
|
|
66
|
+
- Prefer independently verifiable Tasks with stable IDs, but do not split work solely to make Tasks smaller.
|
|
20
67
|
- A Task describes what an agent must accomplish; never emit a shell command as a worker Task.
|
|
21
68
|
- Use dependencies only when execution order actually matters.
|
|
22
69
|
- Every child Task must declare `workerRequirements`; worker-router selects the worker and model-router selects provider/model/reasoning afterward.
|
|
@@ -30,4 +77,11 @@ Relay persists each successful planner invocation as one planner round and creat
|
|
|
30
77
|
|
|
31
78
|
Return exactly one JSON object with `objective_status`, `assessment`, and `next_tasks`.
|
|
32
79
|
|
|
80
|
+
For `objective_status: "in_progress"`, `assessment` must be Markdown containing:
|
|
81
|
+
|
|
82
|
+
1. a compact Mermaid graph of the full remaining objective flow;
|
|
83
|
+
2. a short explanation of the chosen durable Task boundaries.
|
|
84
|
+
|
|
85
|
+
For `objective_status: "satisfied"`, `assessment` may be a concise evidence-based completion assessment without a graph.
|
|
86
|
+
|
|
33
87
|
Each item in `next_tasks` contains `id`, `input`, `workerRequirements`, `output`, and `storySize`. It may also contain priority, projectName, agentName, dependencies, capabilities, maxAttempts, and timeoutMs. Adapter/routing fields are selected by Relay after planning; do not invent them.
|
|
@@ -104,6 +104,10 @@ Relay owns process lifecycle. Workers own semantic task lifecycle. A worker must
|
|
|
104
104
|
|
|
105
105
|
Relay allocates the durable `executionId` before launching model-backed work and gives that worker one exact event source identity: `job/<job-id>/task/<task-id>/execution/<execution-id>`. Every semantic lifecycle event from that execution must copy the identity verbatim in `source.id`. Relay correlates model-worker lifecycle only by this execution identity; `source.agent`, `source.component`, provider/adapter names, and `data.worker_owned` are diagnostic/legacy metadata and MUST NOT authorize or correlate lifecycle state. A retry receives a new execution identity, so events from an older execution are ignored.
|
|
106
106
|
|
|
107
|
+
A durable execution lease survives Relay process/owner restarts until its `leaseExpiresAt`. A different Relay owner is not evidence that the worker died and MUST NOT by itself reclaim or retry that execution. Recovery may reclaim only after the durable lease expires or another authoritative terminal/liveness fact proves the execution ended.
|
|
108
|
+
|
|
109
|
+
For a GitHub-backed Job, every mutating model-backed execution runs from an isolated worktree pinned to that PR's current head SHA. It must not run from or switch the user's primary checkout. Relay verifies the remote repository and exact PR head before launch and fails closed when it cannot prepare that checkout. Concurrent PR Jobs for the same repository use separate execution worktrees.
|
|
110
|
+
|
|
107
111
|
## Job lifecycle and runtime status
|
|
108
112
|
|
|
109
113
|
The canonical durable lifecycle transition graphs are `workflow/job-lifecycle.mmd` and `workflow/task-lifecycle.mmd`. They use Relay's minimal executable Mermaid `stateDiagram-v2` profile and are the single source of truth for transition legality; do not maintain a parallel JSON/YAML transition table. Runtime status is not part of those lifecycle graphs.
|
package/dist/workflow/rollout.js
DELETED
|
@@ -1,185 +0,0 @@
|
|
|
1
|
-
import { jobLifecycleWorkflow, taskLifecycleWorkflow } from './definitions.js';
|
|
2
|
-
import { classifyWorkflowActions, emptyWorkflowDecision, } from './contracts.js';
|
|
3
|
-
import { graphWorkflowDecision } from './runtime.js';
|
|
4
|
-
let modeOverride = null;
|
|
5
|
-
let reporter = null;
|
|
6
|
-
let matches = 0;
|
|
7
|
-
let mismatches = 0;
|
|
8
|
-
export function parseWorkflowRolloutMode(value) {
|
|
9
|
-
if (value === undefined || value === '')
|
|
10
|
-
return 'legacy';
|
|
11
|
-
if (value === 'legacy' || value === 'shadow' || value === 'graph')
|
|
12
|
-
return value;
|
|
13
|
-
throw new Error(`Invalid AGENTS_RELAY_WORKFLOW_MODE '${value}'; expected legacy, shadow, or graph`);
|
|
14
|
-
}
|
|
15
|
-
export function workflowRolloutMode() {
|
|
16
|
-
return modeOverride ?? parseWorkflowRolloutMode(process.env.AGENTS_RELAY_WORKFLOW_MODE);
|
|
17
|
-
}
|
|
18
|
-
export function setWorkflowRolloutMode(mode) { modeOverride = mode; }
|
|
19
|
-
export function setWorkflowShadowReporter(value) { reporter = value; }
|
|
20
|
-
export function resetWorkflowRolloutDiagnostics() { matches = 0; mismatches = 0; }
|
|
21
|
-
export function workflowRolloutSnapshot() {
|
|
22
|
-
return { mode: workflowRolloutMode(), matches, mismatches };
|
|
23
|
-
}
|
|
24
|
-
function accepted(target, guard = null, actions = []) {
|
|
25
|
-
const actionList = [...actions];
|
|
26
|
-
return {
|
|
27
|
-
accepted: true,
|
|
28
|
-
target,
|
|
29
|
-
guard,
|
|
30
|
-
actions: actionList,
|
|
31
|
-
...classifyWorkflowActions(actionList),
|
|
32
|
-
reason: 'accepted',
|
|
33
|
-
};
|
|
34
|
-
}
|
|
35
|
-
const CLEAR = ['mutation.clearError', 'mutation.clearFailureReason'];
|
|
36
|
-
const taskLegacy = {
|
|
37
|
-
DRAFT: {
|
|
38
|
-
release: { target: 'QUEUED', actions: CLEAR },
|
|
39
|
-
block: { target: 'BLOCKED' },
|
|
40
|
-
cancel: { target: 'CANCELLED' },
|
|
41
|
-
},
|
|
42
|
-
QUEUED: {
|
|
43
|
-
hold: { target: 'DRAFT' },
|
|
44
|
-
'task.started': { target: 'RUNNING', actions: CLEAR },
|
|
45
|
-
block: { target: 'BLOCKED' },
|
|
46
|
-
fail: { target: 'FAILED' },
|
|
47
|
-
'effect.launchTask.failed': { target: 'FAILED' },
|
|
48
|
-
cancel: { target: 'CANCELLED' },
|
|
49
|
-
},
|
|
50
|
-
RUNNING: {
|
|
51
|
-
'task.blocked': { target: 'BLOCKED' },
|
|
52
|
-
'task.completed': { target: 'SUCCEEDED', actions: CLEAR },
|
|
53
|
-
'task.failed': { target: 'FAILED' },
|
|
54
|
-
'task.cancelled': { target: 'CANCELLED' },
|
|
55
|
-
},
|
|
56
|
-
BLOCKED: {
|
|
57
|
-
retry: { target: 'QUEUED', actions: CLEAR },
|
|
58
|
-
cancel: { target: 'CANCELLED' },
|
|
59
|
-
},
|
|
60
|
-
SUCCEEDED: {},
|
|
61
|
-
FAILED: {
|
|
62
|
-
retry: { target: 'QUEUED', actions: CLEAR },
|
|
63
|
-
'reconcile.started': { target: 'RUNNING', actions: CLEAR },
|
|
64
|
-
'reconcile.completed': { target: 'SUCCEEDED', actions: CLEAR },
|
|
65
|
-
'reconcile.blocked': { target: 'BLOCKED' },
|
|
66
|
-
cancel: { target: 'CANCELLED' },
|
|
67
|
-
},
|
|
68
|
-
CANCELLED: {},
|
|
69
|
-
};
|
|
70
|
-
const CANCEL_EFFECT = ['effect.cancelActiveTasks'];
|
|
71
|
-
const MERGE_EFFECT = ['effect.mergePullRequest'];
|
|
72
|
-
const jobLegacy = {
|
|
73
|
-
DRAFT: {
|
|
74
|
-
activate: { target: 'ACTIVE' },
|
|
75
|
-
'pr.merged': { target: 'COMPLETED', actions: CANCEL_EFFECT },
|
|
76
|
-
cancel: { target: 'CANCELLED' },
|
|
77
|
-
},
|
|
78
|
-
ACTIVE: {
|
|
79
|
-
'pr.merged': { target: 'COMPLETED', actions: CANCEL_EFFECT },
|
|
80
|
-
cancel: { target: 'CANCELLED' },
|
|
81
|
-
},
|
|
82
|
-
COMPLETED: {
|
|
83
|
-
'effect.cancelActiveTasks.failed': { target: 'COMPLETED' },
|
|
84
|
-
},
|
|
85
|
-
CANCELLED: {
|
|
86
|
-
'pr.merged': { target: 'COMPLETED', actions: CANCEL_EFFECT },
|
|
87
|
-
'effect.cancelActiveTasks.failed': { target: 'CANCELLED' },
|
|
88
|
-
},
|
|
89
|
-
};
|
|
90
|
-
function taskLegacyDecision(from, event, facts) {
|
|
91
|
-
if (event === 'reconcile') {
|
|
92
|
-
if (from === 'DRAFT' && facts.dependenciesSatisfied === true)
|
|
93
|
-
return accepted('QUEUED', 'dependenciesSatisfied', CLEAR);
|
|
94
|
-
if (from === 'QUEUED' && facts.dependenciesUnsatisfied === true)
|
|
95
|
-
return accepted('DRAFT', 'dependenciesUnsatisfied');
|
|
96
|
-
if (from === 'QUEUED' && facts.runnable === true)
|
|
97
|
-
return accepted('QUEUED', 'runnable', CLEAR);
|
|
98
|
-
return emptyWorkflowDecision('no_transition');
|
|
99
|
-
}
|
|
100
|
-
if (event === 'dispatch') {
|
|
101
|
-
if (from === 'QUEUED' && facts.dispatchable === true)
|
|
102
|
-
return accepted('QUEUED', 'dispatchable', ['effect.launchTask']);
|
|
103
|
-
return emptyWorkflowDecision('no_transition');
|
|
104
|
-
}
|
|
105
|
-
const transition = taskLegacy[from][event];
|
|
106
|
-
if (!transition)
|
|
107
|
-
return emptyWorkflowDecision('no_transition');
|
|
108
|
-
return accepted(transition.target, transition.guard ?? null, transition.actions ?? []);
|
|
109
|
-
}
|
|
110
|
-
function jobLegacyDecision(from, event, facts) {
|
|
111
|
-
if (event === 'pr.closed') {
|
|
112
|
-
if ((from === 'DRAFT' || from === 'ACTIVE') && facts.notMerged === true) {
|
|
113
|
-
return accepted('CANCELLED', 'notMerged', CANCEL_EFFECT);
|
|
114
|
-
}
|
|
115
|
-
return emptyWorkflowDecision('no_transition');
|
|
116
|
-
}
|
|
117
|
-
if (event === 'review.approved' || event === 'implementation.completed') {
|
|
118
|
-
if (from === 'ACTIVE' && facts.autoMerge === true)
|
|
119
|
-
return accepted('ACTIVE', 'autoMerge', MERGE_EFFECT);
|
|
120
|
-
return emptyWorkflowDecision('no_transition');
|
|
121
|
-
}
|
|
122
|
-
if (event === 'effect.mergePullRequest.failed' && from === 'ACTIVE')
|
|
123
|
-
return accepted('ACTIVE');
|
|
124
|
-
const transition = jobLegacy[from][event];
|
|
125
|
-
if (!transition)
|
|
126
|
-
return emptyWorkflowDecision('no_transition');
|
|
127
|
-
return accepted(transition.target, transition.guard ?? null, transition.actions ?? []);
|
|
128
|
-
}
|
|
129
|
-
function sameDecision(a, b) {
|
|
130
|
-
return a.accepted === b.accepted
|
|
131
|
-
&& a.target === b.target
|
|
132
|
-
&& a.guard === b.guard
|
|
133
|
-
&& a.reason === b.reason
|
|
134
|
-
&& a.actions.length === b.actions.length
|
|
135
|
-
&& a.actions.every((value, index) => value === b.actions[index])
|
|
136
|
-
&& a.mutations.length === b.mutations.length
|
|
137
|
-
&& a.mutations.every((value, index) => value === b.mutations[index])
|
|
138
|
-
&& a.effects.length === b.effects.length
|
|
139
|
-
&& a.effects.every((value, index) => value === b.effects[index]);
|
|
140
|
-
}
|
|
141
|
-
function report(kind, jobId, taskId, event, from, facts, legacy, graph) {
|
|
142
|
-
const match = sameDecision(legacy, graph);
|
|
143
|
-
if (match)
|
|
144
|
-
matches += 1;
|
|
145
|
-
else
|
|
146
|
-
mismatches += 1;
|
|
147
|
-
const diagnostic = {
|
|
148
|
-
type: match ? 'workflow.shadow.match' : 'workflow.shadow.mismatch',
|
|
149
|
-
kind,
|
|
150
|
-
jobId,
|
|
151
|
-
...(taskId ? { taskId } : {}),
|
|
152
|
-
event,
|
|
153
|
-
from,
|
|
154
|
-
facts: { ...facts },
|
|
155
|
-
legacy,
|
|
156
|
-
graph,
|
|
157
|
-
};
|
|
158
|
-
reporter?.(diagnostic);
|
|
159
|
-
if (!match && !reporter)
|
|
160
|
-
console.warn(JSON.stringify(diagnostic));
|
|
161
|
-
}
|
|
162
|
-
export function decideTaskLifecycle(task, event, facts = {}) {
|
|
163
|
-
const legacy = taskLegacyDecision(task.state, event, facts);
|
|
164
|
-
const mode = workflowRolloutMode();
|
|
165
|
-
if (mode === 'legacy')
|
|
166
|
-
return legacy;
|
|
167
|
-
const graph = graphWorkflowDecision(taskLifecycleWorkflow, task.state, event, facts);
|
|
168
|
-
if (mode === 'shadow') {
|
|
169
|
-
report('task', task.jobId ?? 'unknown', task.id, event, task.state, facts, legacy, graph);
|
|
170
|
-
return legacy;
|
|
171
|
-
}
|
|
172
|
-
return graph;
|
|
173
|
-
}
|
|
174
|
-
export function decideJobLifecycle(job, event, facts = {}) {
|
|
175
|
-
const legacy = jobLegacyDecision(job.state, event, facts);
|
|
176
|
-
const mode = workflowRolloutMode();
|
|
177
|
-
if (mode === 'legacy')
|
|
178
|
-
return legacy;
|
|
179
|
-
const graph = graphWorkflowDecision(jobLifecycleWorkflow, job.state, event, facts);
|
|
180
|
-
if (mode === 'shadow') {
|
|
181
|
-
report('job', job.id, undefined, event, job.state, facts, legacy, graph);
|
|
182
|
-
return legacy;
|
|
183
|
-
}
|
|
184
|
-
return graph;
|
|
185
|
-
}
|