agents-relay 2.0.67 → 2.0.69

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.
Files changed (72) hide show
  1. package/README.md +56 -20
  2. package/dist/adapters.js +39 -3
  3. package/dist/blockers.js +17 -4
  4. package/dist/cli-blocker.js +12 -30
  5. package/dist/cli-help.js +22 -17
  6. package/dist/cli-job-create.js +77 -0
  7. package/dist/cli-local-merge-config.js +11 -0
  8. package/dist/cli-projection.js +23 -55
  9. package/dist/cli-review.js +7 -24
  10. package/dist/cli-support.js +81 -8
  11. package/dist/cli-worker-task.js +63 -0
  12. package/dist/cli.js +223 -185
  13. package/dist/dashboard-html.js +15 -12
  14. package/dist/dashboard.js +27 -2
  15. package/dist/e2e-injection.js +76 -0
  16. package/dist/github-auth.js +4 -3
  17. package/dist/github-binding-order.js +15 -0
  18. package/dist/github-client.js +7 -2
  19. package/dist/github-pr-archive.js +22 -0
  20. package/dist/github-webhook.js +43 -10
  21. package/dist/job-actions.js +29 -0
  22. package/dist/local-authority-store.js +288 -0
  23. package/dist/local-commit-events.js +2 -0
  24. package/dist/local-git-merge.js +124 -0
  25. package/dist/local-job-path.js +7 -0
  26. package/dist/local-job-registry.js +29 -0
  27. package/dist/local-managed-store.js +21 -0
  28. package/dist/local-merge-delivery.js +53 -0
  29. package/dist/local-merge-lock.js +103 -0
  30. package/dist/local-merge-projection.js +60 -0
  31. package/dist/managed-worker-input.js +13 -6
  32. package/dist/manual-plan.js +29 -7
  33. package/dist/no-github.js +11 -0
  34. package/dist/planned-review-head.js +7 -8
  35. package/dist/planner-runtime.js +182 -41
  36. package/dist/planner.js +35 -2
  37. package/dist/pool.js +89 -136
  38. package/dist/projected-store.js +20 -3
  39. package/dist/projection-outbox.js +3 -1
  40. package/dist/publication-store.js +37 -0
  41. package/dist/reconciler-cancellation.js +21 -0
  42. package/dist/reconciler-review.js +99 -29
  43. package/dist/reconciler-runtime.js +72 -35
  44. package/dist/reconciler.js +124 -85
  45. package/dist/relay-store.js +108 -6
  46. package/dist/relayd-control.js +184 -0
  47. package/dist/relayd-dashboard-actions.js +108 -0
  48. package/dist/relayd-utilities.js +107 -0
  49. package/dist/relayd.js +304 -299
  50. package/dist/review-generation-fence.js +12 -0
  51. package/dist/runtime-status-events.js +21 -0
  52. package/dist/runtime-status.js +21 -7
  53. package/dist/scheduled-jobs.js +43 -36
  54. package/dist/service-external-actions.js +27 -0
  55. package/dist/service-publication.js +29 -0
  56. package/dist/store-github.js +17 -6
  57. package/dist/task-actions.js +59 -1
  58. package/dist/task-update.js +1 -1
  59. package/dist/troubleshooter.js +21 -2
  60. package/dist/unblock.js +47 -15
  61. package/dist/worker-rejection.js +11 -0
  62. package/dist/workflow/contracts.js +10 -1
  63. package/dist/workflow/definitions-v2.js +2 -0
  64. package/dist/workflow/job-reconcile-v2.js +9 -60
  65. package/dist/workflow/runtime.js +18 -3
  66. package/dist/workflow-effect-failures.js +30 -0
  67. package/dist/workspace.js +95 -6
  68. package/package.json +6 -3
  69. package/skills/agents-relay/agents/planner.agent.md +3 -0
  70. package/skills/agents-relay/contracts/orchestrator-workflows.md +4 -7
  71. package/workflow/job-lifecycle.mmd +19 -60
  72. package/workflow/review-lifecycle.mmd +5 -8
@@ -1,65 +1,14 @@
1
1
  import { decideJobLifecycle, dispatchJobLifecycle } from './lifecycle.js';
2
- import { implementationComplete } from '../runtime-status.js';
3
- import { applyHistoricalJobCompatibilityRepair } from './runtime.js';
4
- function lifecycleInput(facts) {
5
- if (facts.prState === 'MERGED')
6
- return { event: 'pr.merged', context: {} };
7
- if (facts.prState === 'CLOSED')
8
- return { event: 'pr.closed', context: { notMerged: true } };
9
- return {
10
- event: 'reconcile',
11
- context: {
12
- prOpen: true,
13
- headAvailable: Boolean(facts.headSha),
14
- mergeCapable: facts.mergeCapable,
15
- },
16
- };
17
- }
18
- function graphFacts(job, facts) {
19
- const input = lifecycleInput(facts);
20
- if (input.event !== 'reconcile')
21
- return input.context;
22
- return {
23
- ...input.context,
24
- reviewDisabled: job.reviewEnabled === false,
25
- implementationComplete: implementationComplete(job),
26
- autoMerge: job.autoMerge !== false,
27
- };
28
- }
29
- /**
30
- * Historical compatibility only. Normal Job lifecycle is always decided by
31
- * workflow/job-lifecycle.mmd from the PR fact-derived event.
32
- */
33
- function repairHistoricalTerminalState(job, facts) {
34
- if (['DRAFT', 'ACTIVE', 'COMPLETED', 'CANCELLED'].includes(job.state))
35
- return null;
36
- if (facts.prState === 'MERGED')
37
- applyHistoricalJobCompatibilityRepair(job, 'COMPLETED');
38
- else if (facts.prState === 'CLOSED')
39
- applyHistoricalJobCompatibilityRepair(job, 'CANCELLED');
40
- else
41
- return null;
42
- return job.state;
43
- }
2
+ const localFacts = (_job, facts) => ({
3
+ jobActive: facts.jobActive,
4
+ implementationComplete: facts.implementationComplete,
5
+ headAvailable: facts.headAvailable,
6
+ deliveryReady: facts.deliveryReady,
7
+ });
8
+ /** Local reconciliation cannot invent a completion event; local Git delivery owns it. */
44
9
  export function decideJobReconcile(job, facts) {
45
- const input = lifecycleInput(facts);
46
- return decideJobLifecycle(job, input.event, graphFacts(job, facts));
10
+ return decideJobLifecycle(job, 'reconcile', localFacts(job, facts));
47
11
  }
48
12
  export async function dispatchJobReconcile(job, facts, executeEffect, persist) {
49
- const repaired = repairHistoricalTerminalState(job, facts);
50
- if (repaired) {
51
- await persist?.();
52
- await executeEffect?.('effect.cancelActiveTasks');
53
- return {
54
- accepted: true,
55
- target: repaired,
56
- guard: null,
57
- actions: ['effect.cancelActiveTasks'],
58
- mutations: [],
59
- effects: ['effect.cancelActiveTasks'],
60
- reason: 'accepted',
61
- };
62
- }
63
- const input = lifecycleInput(facts);
64
- return dispatchJobLifecycle(job, input.event, graphFacts(job, facts), executeEffect, persist);
13
+ return dispatchJobLifecycle(job, 'reconcile', localFacts(job, facts), executeEffect, persist);
65
14
  }
@@ -35,15 +35,30 @@ export function applyTaskMutation(task, mutation) {
35
35
  if (mutation === 'mutation.markPlannerUnavailable')
36
36
  task.error = 'No objective planner configured';
37
37
  }
38
- export function applyTaskGraphTarget(task, target, timestamp = new Date().toISOString()) { task.state = target; task.updatedAt = timestamp; }
39
- export function applyJobGraphTarget(job, target, timestamp = new Date().toISOString()) { job.state = target; job.updatedAt = timestamp; }
38
+ export function applyTaskGraphTarget(task, target, timestamp = new Date().toISOString()) {
39
+ const previous = Date.parse(task.updatedAt), candidate = Date.parse(timestamp);
40
+ task.state = target;
41
+ task.updatedAt = Number.isFinite(previous)
42
+ ? new Date(Math.max(Number.isFinite(candidate) ? candidate : Date.now(), previous + 1)).toISOString()
43
+ : timestamp;
44
+ }
45
+ export function applyJobGraphTarget(job, target, timestamp = new Date().toISOString()) {
46
+ const previous = Date.parse(job.updatedAt), candidate = Date.parse(timestamp);
47
+ job.state = target;
48
+ job.updatedAt = Number.isFinite(previous)
49
+ ? new Date(Math.max(Number.isFinite(candidate) ? candidate : Date.now(), previous + 1)).toISOString()
50
+ : timestamp;
51
+ }
40
52
  export function applyTaskWorkflowDecision(task, decision, timestamp = new Date().toISOString()) {
41
53
  if (!decision.accepted || !decision.target)
42
54
  throw new Error(`Cannot apply rejected workflow decision to task ${task.id}`);
43
55
  task.state = decision.target;
44
56
  for (const mutation of decision.mutations)
45
57
  applyTaskMutation(task, mutation);
46
- task.updatedAt = timestamp;
58
+ const previous = Date.parse(task.updatedAt), candidate = Date.parse(timestamp);
59
+ task.updatedAt = Number.isFinite(previous)
60
+ ? new Date(Math.max(Number.isFinite(candidate) ? candidate : Date.now(), previous + 1)).toISOString()
61
+ : timestamp;
47
62
  }
48
63
  export function applyJobWorkflowDecision(job, decision, timestamp = new Date().toISOString()) {
49
64
  if (!decision.accepted || !decision.target)
@@ -0,0 +1,30 @@
1
+ import { transitionJobEvent, transitionTaskEvent } from './scheduler.js';
2
+ import { workflowEffectFailureEvent } from './workflow/contracts.js';
3
+ import { eventFor } from './events.js';
4
+ export async function applyJobEffectOutcomes(store, emit, job, outcomes) {
5
+ for (const outcome of outcomes.filter(item => item.status === 'failed')) {
6
+ const eventName = workflowEffectFailureEvent(outcome.effect);
7
+ try {
8
+ await transitionJobEvent(job, eventName);
9
+ await store.saveJob(job);
10
+ }
11
+ catch {
12
+ // Some effects are observational for a given state; failure is still emitted below.
13
+ }
14
+ await emit(eventFor(job.id, 'job', null, 'workflow.effect.failed', 'failed', `${outcome.effect} failed: ${outcome.error ?? 'unknown error'}`, 'orchestrator', { effect: outcome.effect, idempotencyKey: outcome.key, workflowEvent: eventName }, { agent: 'relay', component: 'agents-relay' }));
15
+ }
16
+ }
17
+ export async function applyTaskEffectOutcomes(store, emit, job, task, outcomes) {
18
+ for (const outcome of outcomes.filter(item => item.status === 'failed')) {
19
+ const eventName = workflowEffectFailureEvent(outcome.effect);
20
+ try {
21
+ await transitionTaskEvent(task, eventName);
22
+ task.error = outcome.error ?? `${outcome.effect} failed`;
23
+ await store.saveTask(task);
24
+ }
25
+ catch {
26
+ // The effect failure remains observable even if this state has no feedback transition.
27
+ }
28
+ await emit(eventFor(job.id, task.id, task.parentTaskId, 'workflow.effect.failed', 'failed', `${outcome.effect} failed: ${outcome.error ?? 'unknown error'}`, 'orchestrator', { effect: outcome.effect, idempotencyKey: outcome.key, workflowEvent: eventName }, { agent: 'relay', component: 'agents-relay' }));
29
+ }
30
+ }
package/dist/workspace.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { lstat, mkdir, readdir, realpath, stat } from 'node:fs/promises';
2
2
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
+ import { createHash } from 'node:crypto';
4
5
  import { spawnOwnedProcess } from './owned-process.js';
5
6
  async function gitOutput(args) {
6
7
  return await new Promise((resolve, reject) => {
@@ -181,6 +182,88 @@ export function managedJobWorktreePath(repository, prNumber, workspaceRoot = def
181
182
  throw new Error('Invalid managed Job checkout identity');
182
183
  return join(workspaceRoot, '.worktrees', 'agents-relay', `${repository.replace('/', '-')}-pr-${prNumber}`, 'job');
183
184
  }
185
+ export function managedLocalJobWorktreePath(repository, jobId, workspaceRoot = defaultWorkspaceRoot()) {
186
+ if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository) || !jobId.trim())
187
+ throw new Error('Invalid local Job checkout identity');
188
+ const safeJob = jobId.replace(/[^A-Za-z0-9_.-]+/g, '-').slice(0, 72) || 'job';
189
+ const digest = createHash('sha256').update(jobId).digest('hex').slice(0, 12);
190
+ return join(workspaceRoot, '.worktrees', 'agents-relay', `${repository.replace('/', '-')}-job-${safeJob}-${digest}`, 'job');
191
+ }
192
+ /** Bind an ordinary SQLite Job to one deterministic local work branch.
193
+ * The target is always main, and branch creation never switches the user's checkout.
194
+ * This is a Git-local operation and never needs GitHub credentials.
195
+ */
196
+ export async function ensureLocalJobSourceBranch(projectPath, job) {
197
+ if (job.localMerge?.sourceRef) {
198
+ if (job.localMerge.targetRef !== 'refs/heads/main')
199
+ throw new Error('Local Job target must be main');
200
+ return job.localMerge.sourceRef;
201
+ }
202
+ const safe = job.id.replace(/[^A-Za-z0-9_.-]+/g, '-').slice(0, 48);
203
+ const digest = createHash('sha256').update(job.id).digest('hex').slice(0, 12);
204
+ const sourceRef = `refs/heads/relay/job-${safe}-${digest}`;
205
+ const primary = (await git(projectPath, ['rev-parse', '--show-toplevel'])).trim();
206
+ await git(primary, ['rev-parse', '--verify', 'refs/heads/main']);
207
+ const exists = await gitOutput(['-C', primary, 'show-ref', '--verify', '--quiet', sourceRef]).then(() => true, () => false);
208
+ if (!exists) {
209
+ // A concurrent caller may create the same ref; git update-ref is idempotent
210
+ // when the target already exists, and must never reset it.
211
+ const sha = (await git(primary, ['rev-parse', '--verify', 'refs/heads/main'])).trim();
212
+ await gitOutput(['-C', primary, 'update-ref', sourceRef, sha, '0000000000000000000000000000000000000000']).catch(async (error) => {
213
+ if (!(await gitOutput(['-C', primary, 'show-ref', '--verify', sourceRef]).then(() => true, () => false)))
214
+ throw error;
215
+ });
216
+ }
217
+ return sourceRef;
218
+ }
219
+ export async function prepareLocalJobExecutionWorktree(projectPath, job, workspaceRoot = defaultWorkspaceRoot()) {
220
+ if (job.prNumber !== 0 || !job.localMerge?.sourceRef)
221
+ return resolveRepositoryProjectPath(projectPath, job.projectPath);
222
+ const sourceRef = job.localMerge.sourceRef;
223
+ if (!/^refs\/heads\/[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(sourceRef) || sourceRef.includes('..') || sourceRef.endsWith('.lock'))
224
+ throw new Error('Local Job execution requires a valid refs/heads sourceRef');
225
+ const primary = await git(projectPath, ['rev-parse', '--show-toplevel']);
226
+ const projectRelative = (await git(projectPath, ['rev-parse', '--show-prefix'])).replace(/\/$/, '');
227
+ let remote = '';
228
+ try {
229
+ remote = await git(primary, ['config', '--get', 'remote.origin.url']);
230
+ }
231
+ catch { /* Local-only repositories may have no origin. */ }
232
+ const remoteRepository = remote ? githubRepositoryFromRemote(remote) : null;
233
+ if (remoteRepository && remoteRepository !== job.repository)
234
+ throw new Error(`Primary checkout ${primary} does not match ${job.repository}`);
235
+ const sourceSha = await git(primary, ['rev-parse', '--verify', sourceRef]);
236
+ const worktree = managedLocalJobWorktreePath(job.repository, job.id, workspaceRoot);
237
+ await mkdir(dirname(worktree), { recursive: true });
238
+ try {
239
+ const existingRoot = await git(worktree, ['rev-parse', '--show-toplevel']);
240
+ if (await realpath(existingRoot) !== await realpath(worktree))
241
+ throw new Error(`Worktree ownership mismatch: ${worktree}`);
242
+ const branch = await git(worktree, ['symbolic-ref', 'HEAD']);
243
+ if (branch !== sourceRef)
244
+ throw new Error(`Local Job worktree is bound to ${branch}, expected ${sourceRef}`);
245
+ const existingHead = await git(worktree, ['rev-parse', 'HEAD']);
246
+ if (existingHead !== sourceSha) {
247
+ if (await git(worktree, ['status', '--porcelain']))
248
+ throw new Error(`Local Job worktree has changes while source ref advanced: ${worktree}`);
249
+ const ancestor = await gitOutput(['-C', worktree, 'merge-base', '--is-ancestor', existingHead, sourceSha]).then(() => true, () => false);
250
+ if (!ancestor)
251
+ throw new Error(`Local Job worktree has non-fast-forward history; preserve commits: ${worktree}`);
252
+ await git(worktree, ['merge', '--ff-only', sourceSha]);
253
+ }
254
+ return projectRelative ? join(worktree, projectRelative) : worktree;
255
+ }
256
+ catch (error) {
257
+ try {
258
+ await stat(worktree);
259
+ }
260
+ catch {
261
+ await git(primary, ['worktree', 'add', worktree, sourceRef.slice('refs/heads/'.length)]);
262
+ return projectRelative ? join(worktree, projectRelative) : worktree;
263
+ }
264
+ throw error;
265
+ }
266
+ }
184
267
  /**
185
268
  * Graph-owned eligibility: called only after authoritative MERGED -> COMPLETED.
186
269
  * This function only performs the mechanical, conservative cleanup effect.
@@ -188,13 +271,14 @@ export function managedJobWorktreePath(repository, prNumber, workspaceRoot = def
188
271
  export async function cleanupCompletedJobWorktree(projectPath, job, workspaceRoot = defaultWorkspaceRoot()) {
189
272
  if (job.state !== 'COMPLETED')
190
273
  return { status: 'blocked', reason: 'Job is not completed' };
191
- if (!job.headSha)
192
- return { status: 'blocked', reason: 'No durable PR head SHA' };
274
+ const expectedHead = job.prNumber === 0 ? (job.localMerge?.sourceSha ?? job.review?.headSha ?? job.headSha) : job.headSha;
275
+ if (!expectedHead)
276
+ return { status: 'blocked', reason: 'No durable Job head SHA' };
193
277
  if (job.tasks.some(task => task.executionId !== null || task.leaseOwner !== null))
194
278
  return { status: 'blocked', reason: 'Task still owns an execution or lease' };
195
279
  if ((job.plans ?? []).some(plan => plan.executionId !== null || plan.leaseOwner !== null))
196
280
  return { status: 'blocked', reason: 'Planner still owns an execution or lease' };
197
- const worktree = managedJobWorktreePath(job.repository, job.prNumber, workspaceRoot);
281
+ const worktree = job.prNumber === 0 ? managedLocalJobWorktreePath(job.repository, job.id, workspaceRoot) : managedJobWorktreePath(job.repository, job.prNumber, workspaceRoot);
198
282
  let metadata;
199
283
  try {
200
284
  metadata = await lstat(worktree);
@@ -207,8 +291,13 @@ export async function cleanupCompletedJobWorktree(projectPath, job, workspaceRoo
207
291
  if (!metadata.isDirectory() || metadata.isSymbolicLink())
208
292
  return { status: 'blocked', reason: 'Worktree path is not an owned directory' };
209
293
  const primary = await git(projectPath, ['rev-parse', '--show-toplevel']);
210
- const origin = await git(primary, ['config', '--get', 'remote.origin.url']);
211
- if (githubRepositoryFromRemote(origin) !== job.repository)
294
+ let origin = '';
295
+ try {
296
+ origin = await git(primary, ['config', '--get', 'remote.origin.url']);
297
+ }
298
+ catch { /* Local-only repositories may have no origin. */ }
299
+ const originRepository = origin ? githubRepositoryFromRemote(origin) : null;
300
+ if ((job.prNumber > 0 && originRepository !== job.repository) || (job.prNumber === 0 && originRepository && originRepository !== job.repository))
212
301
  return { status: 'blocked', reason: 'Canonical repository identity mismatch' };
213
302
  const registered = await git(primary, ['worktree', 'list', '--porcelain']);
214
303
  const ownedRoot = await realpath(worktree);
@@ -224,7 +313,7 @@ export async function cleanupCompletedJobWorktree(projectPath, job, workspaceRoo
224
313
  if (!matching.includes(true))
225
314
  return { status: 'blocked', reason: 'Worktree is not registered with the canonical repository' };
226
315
  const head = await git(worktree, ['rev-parse', 'HEAD']);
227
- if (head !== job.headSha)
316
+ if (head !== expectedHead)
228
317
  return { status: 'blocked', reason: 'Worktree contains unique or stale commits' };
229
318
  if (await git(worktree, ['status', '--porcelain']))
230
319
  return { status: 'blocked', reason: 'Worktree has uncommitted or untracked changes' };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-relay",
3
- "version": "2.0.67",
3
+ "version": "2.0.69",
4
4
  "description": "Durable async agent jobs coordinated through GitHub pull requests",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,10 +9,13 @@
9
9
  },
10
10
  "scripts": {
11
11
  "build": "tsc -p tsconfig.json && chmod +x dist/cli.js dist/relayd.js dist/relayd-main.js",
12
- "test": "npm run build && node --test test/*.test.js",
12
+ "test": "npm run test:full",
13
13
  "test:chatgpt-temp-quality": "AGENTS_RELAY_RUN_CHATGPT_TEMP_QUALITY=1 sh -c \"npm run build && node test/quality/run-temporary-chat.mjs\"",
14
14
  "dev": "tsx src/cli.ts",
15
- "test:publish": "npm run build && node --test --test-force-exit test/*.test.js"
15
+ "test:publish": "npm run build && node --test --test-concurrency=1 --test-force-exit test/*.test.js",
16
+ "test:fast": "npm run build && node --test --test-concurrency=2 test/workflow-lifecycle.test.js test/workflow-v2-lifecycle-parity.test.js test/workflow-effects.test.js test/relay-store.test.js test/local-authority.test.js test/local-jobid-routing.test.js test/local-sqlite-jobid.test.js test/no-github.test.js test/scheduled-jobs.test.js test/reconcile-coordinator.test.js test/service-publication.test.js test/github-webhook.test.js",
17
+ "test:full": "npm run build && node --test --test-concurrency=1 test/*.test.js",
18
+ "e2e": "node e2e/runner/run.mjs"
16
19
  },
17
20
  "devDependencies": {
18
21
  "@types/node": "^22.10.2",
@@ -81,6 +81,7 @@ Do not create separate Tasks merely for "analyze", "format", "call tool", "check
81
81
  - Treat durable Job/Task state as authoritative.
82
82
  - Discover visible agents from the resolved execution context before selecting an agent. Choose an existing visible agent only when it is appropriate for the Task; otherwise leave `agentName` unset. Agent discovery is runtime/context-specific, not a hardcoded global list.
83
83
  - Build `agentGraph` nodes only from agents in the discovered visible-agent catalog. Every named graph agent must be visible in the resolved planning/execution context.
84
+ - `agentGraph` is optional. For a single agent use `flowchart LR\n implementation[agent:implementation]` (replace with the actual visible agent); do **not** create an edge from an agent/node to itself. Every edge must connect distinct nodes, and all edges must form an acyclic directed graph. If no multi-agent orchestration is needed, omit `agentGraph` entirely.
84
85
  - Never invent completed work or evidence.
85
86
  - First decide whether the objective is already satisfied.
86
87
  - If satisfied, return `objective_status: "satisfied"` and `next_tasks: []`.
@@ -98,6 +99,8 @@ Do not create separate Tasks merely for "analyze", "format", "call tool", "check
98
99
  - Every child Task must declare exactly one output: `task_pr` or `file`.
99
100
  - Every child Task must include `storySize` (`S`, `M`, or `L`); use `M` when uncertain.
100
101
  - Do not create bookkeeping, waiting, status-checking, wrapper, or planning Tasks. Your response itself is the planning round.
102
+ - Relay owns lifecycle delivery for local-first Jobs. Never create a worker Task whose job is to perform the local Git merge, mark the Job `COMPLETED`, merge the projected GitHub PR, or wait/check for those Relay-owned effects. After implementation and required review are satisfied, return no lifecycle-completion Task; let the canonical Review/Local-Merge/Job Graph advance the Job.
103
+ - When all executable implementation work is complete and the only remaining steps are Relay-owned final review, local merge, Job completion, or projected PR convergence, return `objective_status: "satisfied"` with `next_tasks: []`. Do not return `in_progress` with an empty task list before final review; that would leave the Job with no executable progress path.
101
104
  - Return no prose outside the JSON result.
102
105
 
103
106
  ## Output
@@ -8,22 +8,19 @@ This contract defines orchestrator-owned exception workflows that sit above norm
8
8
 
9
9
  ## Normal managed Job start workflow
10
10
 
11
- For a normal managed Job, `job create` or `job adopt` establishes durable Job/PR state only. Unless the user explicitly requested **create-only / no execution**, the orchestrator must immediately start the Job by invoking the first-class Planner command for that exact Job:
11
+ For a normal managed Job, the preferred bootstrap is one command:
12
12
 
13
13
  ```text
14
- job create|adopt
15
- -> agents-relay plan --repo <owner/repo> --pr <number> --id <job_id>
14
+ job create --initial-plan
16
15
  -> durable plan-N
17
16
  -> Planner next_tasks
18
17
  -> Relay materializes Tasks
19
18
  -> normal routing, execution, and reconciliation
20
19
  ```
21
20
 
22
- The initial decomposition for a normal managed Job belongs to that durable Planner round. The orchestrator must not hand-author the initial child Task batch as a substitute for `agents-relay plan`, and it must not leave a newly created executable Job with zero Plans and zero Tasks. If the Planner command fails, the Job is blocked at startup and the orchestrator must report the failure rather than silently inventing Tasks.
21
+ Without `--initial-plan`, `job create` remains create-only and establishes durable Job/PR state without execution. `agents-relay plan` remains available for an existing create-only/adopted Job. Unless the user explicitly requested **create-only / no execution**, the orchestrator should use `job create --initial-plan` rather than issuing two separate commands.
23
22
 
24
- This rule applies regardless of whether the Job uses `fixed` or `autonomous` execution mode; execution mode does not replace the required initial Planner invocation. Later replanning continues to follow Relay's normal Planner/reconciliation rules.
25
-
26
- The explicit exception is the P0/self-job workflow below: a self job is synchronous orchestrator-owned execution, so it creates orchestrator-owned Tasks directly and terminalizes them in the same turn instead of invoking the normal initial Planner round.
23
+ The initial decomposition belongs to that durable Planner round. The orchestrator must not hand-author the initial child Task batch as a substitute. If initial planning fails, the create command fails visibly after preserving the durable Job/Plan evidence; the orchestrator must not silently invent Tasks. This applies regardless of whether the Job uses `fixed` or `autonomous` execution mode. Later replanning continues to follow Relay's normal Planner/reconciliation rules.
27
24
 
28
25
  ## P0 / self job workflow
29
26
 
@@ -4,73 +4,32 @@ draft["state: DRAFT"]
4
4
  active["state: ACTIVE"]
5
5
  completed["state: COMPLETED"]
6
6
  cancelled["state: CANCELLED"]
7
- notMergedDraft["guard: notMerged"]
8
- notMergedActive["guard: notMerged"]
9
- autoMergeReview["guard: autoMerge"]
10
- autoMergeImplementation["guard: autoMerge"]
11
- prOpenReconcile["guard: prOpen"]
12
- reviewDisabledReconcile["guard: reviewDisabled"]
13
- implementationCompleteReconcile["guard: implementationComplete"]
14
- autoMergeReconcile["guard: autoMerge"]
15
- headAvailableReconcile["guard: headAvailable"]
16
- mergeCapableReconcile["guard: mergeCapable"]
17
- mergeReconcile["action: effect.mergePullRequest"]
18
- cancelDraftMerge["action: effect.cancelActiveTasks"]
19
- cancelDraftClose["action: effect.cancelActiveTasks"]
20
- mergeReview["action: effect.mergePullRequest"]
21
- mergeImplementation["action: effect.mergePullRequest"]
22
- cancelActiveMerge["action: effect.cancelActiveTasks"]
23
- cancelActiveClose["action: effect.cancelActiveTasks"]
24
- cancelCancelledMerge["action: effect.cancelActiveTasks"]
7
+
8
+ cancelDraft["action: effect.cancelActiveTasks"]
9
+ cancelActive["action: effect.cancelActiveTasks"]
10
+ localCompletionGuard["guard: localCompletionAllowed"]
11
+ localMergeReadyGuard["guard: localMergeReady"]
12
+ localMergeDelivery["action: effect.deliverLocalMerge"]
25
13
 
26
14
  draft -->|on: activate| active
27
- draft -->|on: pr.merged| cancelDraftMerge
28
- cancelDraftMerge --> completed
29
15
  draft -->|on: cancel| cancelDraft
30
- cancelDraft["action: effect.cancelActiveTasks"]
31
16
  cancelDraft --> cancelled
32
- draft -->|on: pr.closed| notMergedDraft
33
- notMergedDraft -->|true| cancelDraftClose
34
- cancelDraftClose --> cancelled
35
-
36
- active -->|on: reconcile| prOpenReconcile
37
- prOpenReconcile -->|true| reviewDisabledReconcile
38
- prOpenReconcile -->|false| active
39
- reviewDisabledReconcile -->|true| implementationCompleteReconcile
40
- reviewDisabledReconcile -->|false| active
41
- implementationCompleteReconcile -->|true| autoMergeReconcile
42
- implementationCompleteReconcile -->|false| active
43
- autoMergeReconcile -->|true| headAvailableReconcile
44
- autoMergeReconcile -->|false| active
45
- headAvailableReconcile -->|true| mergeCapableReconcile
46
- headAvailableReconcile -->|false| active
47
- mergeCapableReconcile -->|true| mergeReconcile
48
- mergeCapableReconcile -->|false| active
49
- mergeReconcile --> active
50
17
 
51
- active -->|on: review.approved| autoMergeReview
52
- autoMergeReview -->|true| mergeReview
53
- mergeReview --> active
54
- active -->|on: implementation.completed| autoMergeImplementation
55
- autoMergeImplementation -->|true| mergeImplementation
56
- mergeImplementation --> active
57
- active -->|on: effect.mergePullRequest.failed| active
58
- active -->|on: pr.merged| cancelActiveMerge
59
- cancelActiveMerge --> completed
18
+ active -->|on: reconcile| active
19
+ active -->|on: review.approved| active
20
+ active -->|on: implementation.completed| active
60
21
  active -->|on: cancel| cancelActive
61
- cancelActive["action: effect.cancelActiveTasks"]
62
22
  cancelActive --> cancelled
63
- active -->|on: pr.closed| notMergedActive
64
- notMergedActive -->|true| cancelActiveClose
65
- cancelActiveClose --> cancelled
66
23
 
67
- cancelled -->|on: pr.merged| cancelCancelledMerge
68
- cancelCancelledMerge --> completed
69
- cancelled -->|on: effect.cancelActiveTasks.failed| cancelled
70
- completed -->|on: effect.cancelActiveTasks.failed| completed
24
+ active -->|on: local.completed| localCompletionGuard
25
+ localCompletionGuard -->|true| completed
26
+ localCompletionGuard -->|false| active
71
27
 
72
- %% Cleanup eligibility belongs to the lifecycle graph and is retryable after restart.
73
- cleanupCompletedWorktree["action: effect.cleanupJobWorktree"]
74
- completed -->|on: pr.merged| cleanupCompletedWorktree
75
- cleanupCompletedWorktree --> completed
28
+ active -->|on: local.merge.requested| localMergeReadyGuard
29
+ localMergeReadyGuard -->|true| localMergeDelivery
30
+ localMergeDelivery --> active
31
+ localMergeReadyGuard -->|false| active
32
+ active -->|on: effect.deliverLocalMerge.failed| active
33
+
34
+ cancelled -->|on: effect.cancelActiveTasks.failed| cancelled
76
35
  completed -->|on: effect.cleanupJobWorktree.failed| completed
@@ -10,7 +10,6 @@ stale["state: STALE"]
10
10
 
11
11
  unscheduledNone["guard: unscheduled"]
12
12
  reviewEnabledNone["guard: reviewEnabled"]
13
- prOpenNone["guard: prOpen"]
14
13
  jobActiveNone["guard: jobActive"]
15
14
  implementationCompleteNone["guard: implementationComplete"]
16
15
  headAvailableNone["guard: headAvailable"]
@@ -19,7 +18,6 @@ noActiveReviewNone["guard: noActiveReviewTask"]
19
18
  noApprovedReviewNone["guard: noApprovedCurrentReview"]
20
19
  unscheduledStale["guard: unscheduled"]
21
20
  reviewEnabledStale["guard: reviewEnabled"]
22
- prOpenStale["guard: prOpen"]
23
21
  jobActiveStale["guard: jobActive"]
24
22
  implementationCompleteStale["guard: implementationComplete"]
25
23
  headAvailableStale["guard: headAvailable"]
@@ -41,16 +39,15 @@ cancelChanges["action: review.cancelStale"]
41
39
  cancelBlocked["action: review.cancelStale"]
42
40
  fixPending["action: review.createFix"]
43
41
  fixStarted["action: review.createFix"]
42
+ fixChanges["action: review.createFix"]
44
43
  mergePending["action: review.requestMerge"]
45
44
  mergeStarted["action: review.requestMerge"]
46
45
 
47
46
  none -->|on: reconcile| unscheduledNone
48
47
  unscheduledNone -->|true| reviewEnabledNone
49
48
  unscheduledNone -->|false| none
50
- reviewEnabledNone -->|true| prOpenNone
49
+ reviewEnabledNone -->|true| jobActiveNone
51
50
  reviewEnabledNone -->|false| none
52
- prOpenNone -->|true| jobActiveNone
53
- prOpenNone -->|false| none
54
51
  jobActiveNone -->|true| implementationCompleteNone
55
52
  jobActiveNone -->|false| none
56
53
  implementationCompleteNone -->|true| headAvailableNone
@@ -68,10 +65,8 @@ createNone --> pending
68
65
  stale -->|on: reconcile| unscheduledStale
69
66
  unscheduledStale -->|true| reviewEnabledStale
70
67
  unscheduledStale -->|false| stale
71
- reviewEnabledStale -->|true| prOpenStale
68
+ reviewEnabledStale -->|true| jobActiveStale
72
69
  reviewEnabledStale -->|false| stale
73
- prOpenStale -->|true| jobActiveStale
74
- prOpenStale -->|false| stale
75
70
  jobActiveStale -->|true| implementationCompleteStale
76
71
  jobActiveStale -->|false| stale
77
72
  implementationCompleteStale -->|true| headAvailableStale
@@ -111,6 +106,8 @@ autoMergeStarted -->|false| approved
111
106
  started -->|on: review.changes_required| fixStarted
112
107
  fixStarted --> changes
113
108
  started -->|on: review.blocked| blocked
109
+ changes -->|on: review.changes_required| fixChanges
110
+ fixChanges --> changes
114
111
 
115
112
  pending -->|on: head.changed| cancelPending
116
113
  cancelPending --> stale