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 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' });
@@ -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
- 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 });
191
- if (task.state === 'QUEUED') {
192
- this.clearExecution(task.id, executionId);
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) {
@@ -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
- 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());
@@ -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 hasActiveRecovery(job, original) {
42
- return job.tasks.some(task => recoveryTargetsTask(task, original) && ACTIVE_RECOVERY_STATES.has(task.state));
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) || hasActiveRecovery(job, task))
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 './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.97",
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 small independently verifiable Tasks with stable IDs.
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.
@@ -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
- }