agents-relay 1.0.98 → 1.0.100
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.js +33 -19
- package/dist/scheduler.js +1 -1
- package/dist/store-github.js +7 -7
- 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/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' });
|
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) {
|
|
@@ -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());
|
|
@@ -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.100",
|
|
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
|
},
|
|
@@ -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
|
-
}
|