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 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-engine rollout is confidence-gated with `AGENTS_RELAY_WORKFLOW_MODE=legacy|shadow|graph` (default `legacy`). `shadow` keeps legacy authoritative while comparing the Mermaid decision and exposing `{mode,matches,mismatches}` at `/api/workflow-rollout`; `graph` is explicit opt-in until the shadow cutover gate is approved.
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' });
@@ -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
- const ownerLost = task.leaseOwner !== null && task.leaseOwner !== this.options.owner;
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 = expired ? 'Lease expired' : 'Execution owner restarted';
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: expired ? 'lease_expired' : 'execution_owner_lost' }));
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 workerInput = await managedWorkerInput(job, task, executionContext);
363
- const workerTask = { ...task, input: workerInput, routing: task.routing ? { ...task.routing, cwd: task.routing.cwd ?? executionContext.project.path } : task.routing };
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/rollout.js';
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';
@@ -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 headSha() {
90
- const { owner, name } = this.repoParts();
91
- 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 });
92
- const repository = data.repository;
93
- const pr = repository?.pullRequest;
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 './rollout.js';
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.98",
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.
@@ -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
- }