agents-relay 2.0.92 → 2.0.94

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
@@ -58,7 +58,7 @@ These paths always represent the current canonical graphs. Historical graph defi
58
58
 
59
59
  ## Quick start
60
60
 
61
- For new work, create a local Job through Agents Relay. The CLI can bind a known PR number without GitHub access; creating a new PR head uses the external GitHub adapter. Tasks are validated against the SQLite Job, not GitHub markers.
61
+ For new work, create a local Job through Agents Relay. Use `job adopt` to bind a known legacy PR number without GitHub access; the external GitHub projection creates a display PR after the first meaningful code commit. Tasks are validated against the SQLite Job, not GitHub markers.
62
62
 
63
63
  ~~~sh
64
64
  cat > job.md <<'EOF'
@@ -67,7 +67,7 @@ cat > job.md <<'EOF'
67
67
  Standalone Job context. Put a Mermaid block first when a workflow/state/architecture diagram helps.
68
68
  EOF
69
69
 
70
- npx agents-relay job create --repo OWNER/REPO --head feat/example --base main \
70
+ npx agents-relay job create --repo OWNER/REPO \
71
71
  --id job-1 --title "Objective" --objective-file job.md --mode fixed
72
72
 
73
73
  # The orchestrator decides the initial batch separately
@@ -76,10 +76,10 @@ cat > task.md <<'EOF'
76
76
 
77
77
  Implement this phase using the Job objective as context.
78
78
  EOF
79
- npx agents-relay task create --repo OWNER/REPO --pr 12 --id job-1 \
79
+ npx agents-relay task create --repo OWNER/REPO --id job-1 \
80
80
  --task-id initial --output task-pr --input-file task.md
81
81
 
82
- npx agents-relay task create --repo OWNER/REPO --pr 12 --id job-1 \
82
+ npx agents-relay task create --repo OWNER/REPO --id job-1 \
83
83
  --task-id child --output task-pr \
84
84
  --input "Implement the described child task and publish a terminal event."
85
85
 
@@ -142,12 +142,12 @@ Autonomous jobs can also be scheduled. The Job marker stores a five-field cron e
142
142
  [Scheduled autonomous jobs](docs/scheduled-autonomous-jobs.md) for the exact
143
143
  operational contract.
144
144
 
145
- job create first resolves an existing open PR with the same head/base, then creates it only when needed. Re-running it is idempotent: it reuses the PR and maintains exactly one trusted agents-relay:job:v1 marker. Job creation is always create-only and always enables auto-merge; `--no-auto-merge` is not a `job create` option. Existing Jobs can still change that setting with `job update`. Task execution options belong to `task create`, and the orchestrator may create zero, one, or multiple initial Tasks according to user intent.
145
+ GitHub projection creates or reuses a display PR after a meaningful source-branch commit; `job adopt` is reserved for existing legacy PR bindings. Re-running it is idempotent: it reuses the PR and maintains exactly one trusted agents-relay:job:v1 marker. Job creation is always create-only and always enables auto-merge; `--no-auto-merge` is not a `job create` option. Existing Jobs can still change that setting with `job update`. Task execution options belong to `task create`, and the orchestrator may create zero, one, or multiple initial Tasks according to user intent.
146
146
 
147
- To create a Job on an existing unmanaged PR, use the same `job create` action with `--pr` instead of `--head`:
147
+ To create a Job on an existing unmanaged PR, use the explicit `job adopt` compatibility action:
148
148
 
149
149
  ~~~sh
150
- npx agents-relay job create --repo OWNER/REPO --pr 6 --id job-6 --title "Existing objective"
150
+ npx agents-relay job adopt --repo OWNER/REPO --pr 6 --id job-6 --title "Existing objective"
151
151
  ~~~
152
152
 
153
153
  Use `job update` for partial mutable metadata changes on an already healthy Job marker/binding:
package/dist/cli-help.js CHANGED
@@ -56,15 +56,17 @@ When provided, --thread-id persists a reviewer destination; otherwise the Job re
56
56
 
57
57
  Required: --repo OWNER/REPO --pr NUMBER --id JOB_ID.
58
58
  Optional: --thread-id THREAD_ID persists a reviewer override; omitted uses Job reviewer/creator thread or legacy route. Rejects pending reviews and wakes managed reconciliation.`,
59
- 'job create': `Create a SQLite-authoritative Job.
59
+ 'job create': `Create a SQLite-authoritative Job (objective-only).
60
60
 
61
- Required: --repo OWNER/REPO and a standalone Job objective (unless binding an existing PR). Job ID defaults to an auto-incrementing repository-scoped number; --id is optional for compatibility.
62
- With --head, bind the local source branch and use --base as its local merge target without a GitHub query. With --pr, bind an existing external PR reference as optional metadata without a GitHub query. Without either, create a local-first content Job; local Job completion still follows the configured Graph lifecycle.
63
- Common: --id JOB_ID (optional), --idempotency-key KEY (for retries), --base BRANCH (default: main), --title TEXT, --objective-file PATH|- (preferred), --objective MARKDOWN (short/simple only), --project-path RELATIVE_PATH, --priority P0|P1|P2|P3, --mode fixed|autonomous, --initial-plan, --no-review, --tag JSON, --reviewer-thread-id ID, --creator-thread-id ID, --implementation-thread-id ID. --objective and --objective-file are mutually exclusive. For a new --head Job, one is required and must contain enough standalone context to complete the Job without the originating chat/thread. Objective Markdown is also the default PR body; --body is an explicit override.
64
- Job creation always enables auto-merge. By default it is create-only; --initial-plan follows durable Job creation with the first real Planner round, activates the Job, and publishes the normal wake so Relay can continue without a second orchestrator command. Re-running create against an already healthy Job is idempotent and does not mutate its metadata.
61
+ Required: --repo OWNER/REPO and a standalone Job objective. Job ID defaults to an auto-incrementing repository-scoped number; --id is optional for compatibility. Existing PR adoption is an explicit separate command: job adopt.
62
+ Common: --id JOB_ID (optional), --title TEXT, --objective-file PATH|- (preferred), --objective MARKDOWN (short/simple only), --project-path RELATIVE_PATH, --priority P0|P1|P2|P3, --mode fixed|autonomous, --initial-plan, --no-review, --tag JSON, --reviewer-thread-id ID, --creator-thread-id ID, --implementation-thread-id ID. --objective and --objective-file are mutually exclusive.
63
+ Job creation always enables auto-merge. By default it is create-only; --initial-plan validates the first real Planner round before atomically creating the durable Job and Plan, then publishes the normal wake so Relay can continue without a second orchestrator command. Re-running create against an already healthy Job is idempotent and does not mutate its metadata.
65
64
 
66
65
  Example:
67
- npx agents-relay job create --repo OWNER/REPO --project-path vlog --title "Episode" --objective-file episode.md --idempotency-key submission-123 --initial-plan`,
66
+ npx agents-relay job create --repo OWNER/REPO --project-path vlog --title "Episode" --objective-file episode.md --initial-plan`,
67
+ 'job adopt': `Adopt an existing legacy PR as a SQLite-authoritative Job.
68
+
69
+ Required: --repo OWNER/REPO, --pr NUMBER, and --id JOB_ID. This is the only creation path that binds an existing PR.`,
68
70
  'job update': `Partially update mutable metadata on a healthy existing Job.
69
71
 
70
72
  Required: --repo OWNER/REPO, --pr NUMBER, and --id JOB_ID.
@@ -4,8 +4,9 @@ import { createJobEntity } from './workflow/entity-creation.js';
4
4
  import { normalizeThreadId } from './reviewer-thread.js';
5
5
  import { discoverLocalRelayStoreSnapshots, relayStoreDirectory } from './relay-store.js';
6
6
  /**
7
- * Job lifecycle bootstrap: SQLite is authoritative. No GitHub request is
8
- * required; an explicit PR number is optional external metadata.
7
+ * Ordinary Job creation is local and objective-oriented. Legacy PR metadata is
8
+ * handled by adoptLegacyJob below so the normal contract cannot accidentally
9
+ * create a second external lifecycle.
9
10
  */
10
11
  export async function ensureManagedGitHubJob(_client, repositoryName, _trustedAuthors, options) {
11
12
  const directory = options.storeDirectory ?? relayStoreDirectory();
@@ -46,7 +47,6 @@ export async function ensureManagedGitHubJob(_client, repositoryName, _trustedAu
46
47
  throw new Error('Local merge target must always be main');
47
48
  if (options.base && options.base !== 'main')
48
49
  throw new Error('Job base must always be main');
49
- const localMergeTarget = localMergeSource ? 'refs/heads/main' : undefined;
50
50
  const job = createJobEntity({
51
51
  id,
52
52
  title: options.title || pr.title || 'Agents Relay job',
@@ -57,7 +57,7 @@ export async function ensureManagedGitHubJob(_client, repositoryName, _trustedAu
57
57
  executionMode: options.executionMode ?? 'fixed',
58
58
  tag: options.tag,
59
59
  autoMerge: true,
60
- ...(localMergeSource && localMergeTarget ? { localMerge: { sourceRef: localMergeSource, targetRef: localMergeTarget, status: 'pending' } } : {}),
60
+ ...(localMergeSource ? { localMerge: { sourceRef: localMergeSource, targetRef: 'refs/heads/main', status: 'pending' } } : {}),
61
61
  reviewEnabled: options.reviewEnabled ?? true,
62
62
  ...(options.reviewerThreadId
63
63
  ? { reviewerThreadId: normalizeThreadId(options.reviewerThreadId, 'reviewer thread ID') }
@@ -76,8 +76,43 @@ export async function ensureManagedGitHubJob(_client, repositoryName, _trustedAu
76
76
  tasks: [],
77
77
  ...(options.schedule ? { schedule: options.schedule } : {}),
78
78
  }, { activate: Boolean(options.schedule) });
79
- await local.store.saveJob(job);
80
- return { job: await local.store.load(id), pr };
79
+ if (options.persist !== false) {
80
+ if (local.store.createJobIfAbsent)
81
+ await local.store.createJobIfAbsent(job);
82
+ else
83
+ await local.store.saveJob(job);
84
+ }
85
+ return { job: options.persist === false ? job : await local.store.load(id), pr };
86
+ }
87
+ finally {
88
+ local.relay.close();
89
+ }
90
+ }
91
+ /** Explicit compatibility path for adopting an already-existing PR. */
92
+ export async function adoptLegacyJob(_client, repositoryName, _trustedAuthors, options) {
93
+ if (!options.id || !Number.isInteger(options.prNumber) || options.prNumber <= 0)
94
+ throw new Error('job adopt requires --id and a positive --pr');
95
+ const directory = options.storeDirectory ?? relayStoreDirectory();
96
+ const matches = (await discoverLocalRelayStoreSnapshots(directory)).flatMap(snapshot => snapshot.jobs).filter(job => job.id === options.id);
97
+ if (matches.length > 1)
98
+ throw new Error(`Ambiguous existing immutable Job ID ${options.id}`);
99
+ if (matches.length === 1) {
100
+ const existing = matches[0];
101
+ if (existing.repository !== repositoryName || existing.prNumber !== options.prNumber)
102
+ throw new Error(`Job ID ${options.id} is already bound to another repository or PR`);
103
+ return { job: existing, pr: { number: existing.prNumber, title: existing.title, body: existing.objective ?? '', state: 'OPEN', head: '', base: 'main' } };
104
+ }
105
+ const local = openLocalManagedStore(repositoryName, options.prNumber, { directory, jobId: options.id, noGitHub: true });
106
+ try {
107
+ const existing = await local.relay.loadJob(options.id);
108
+ if (existing)
109
+ return { job: existing, pr: { number: existing.prNumber, title: existing.title, body: existing.objective ?? '', state: 'OPEN', head: '', base: '' } };
110
+ const now = new Date().toISOString();
111
+ const job = createJobEntity({ id: options.id, title: options.title ?? options.id, objective: options.objective, projectPath: options.projectPath,
112
+ description: options.objective, priority: 'P2', executionMode: 'fixed', autoMerge: true, reviewEnabled: true,
113
+ prNumber: options.prNumber, repository: repositoryName, continuation: null, createdAt: now, updatedAt: now, tasks: [] });
114
+ const created = local.store.createJobIfAbsent ? await local.store.createJobIfAbsent(job) : (await local.store.saveJob(job), job);
115
+ return { job: created, pr: { number: options.prNumber, title: created.title, body: created.objective ?? '', state: 'OPEN', head: '', base: 'main' } };
81
116
  }
82
117
  finally {
83
118
  local.relay.close();
package/dist/cli.js CHANGED
@@ -23,7 +23,7 @@ import { applyJobActivateAction, applyJobCancelAction } from './job-actions.js';
23
23
  import { resolveExecutionContext } from './execution-context.js';
24
24
  import { startServiceExternalActionFlusher } from './service-external-actions.js';
25
25
  import { handleServicePublication } from './service-publication.js';
26
- import { defaultWorkspaceRoot, prepareLocalJobExecutionWorktree, resolveManagedProjectPath, resolveRepositoryProjectPath } from './workspace.js';
26
+ import { defaultWorkspaceRoot, ensureLocalJobSourceBranch, jobRequiresManagedWorktree, prepareLocalJobExecutionWorktree, resolveManagedProjectPath, resolveRepositoryProjectPath } from './workspace.js';
27
27
  import { runBlockerCommand } from './cli-blocker.js';
28
28
  import { runEscalationCommand } from './cli-escalation.js';
29
29
  import { runProjectionCommand } from './cli-projection.js';
@@ -363,7 +363,7 @@ async function runContextCommand(action, args) {
363
363
  console.log(JSON.stringify(context, null, 2));
364
364
  }
365
365
  async function runJobCommand(action, args) {
366
- if (!['create', 'update', 'show', 'list', 'merge', 'unblock', 'cancel'].includes(action))
366
+ if (!['create', 'adopt', 'update', 'show', 'list', 'merge', 'unblock', 'cancel'].includes(action))
367
367
  throw new Error(usage);
368
368
  const repo = repository(args);
369
369
  let createObjective;
@@ -375,8 +375,23 @@ async function runJobCommand(action, args) {
375
375
  if (invalid)
376
376
  throw new Error(`job create does not accept task execution option ${invalid}; create the job first, then let the orchestrator create the initial plan/task batch`);
377
377
  createObjective = await textOption(args, '--objective', '--objective-file');
378
- if ((!arg(args, '--pr') || noGitHubMode(args)) && !createObjective.trim())
379
- throw new Error('job create with --head requires --objective-file PATH|- or --objective MARKDOWN containing the complete standalone Job context');
378
+ if (!createObjective.trim())
379
+ throw new Error('job create requires --objective-file PATH|- or --objective MARKDOWN containing the complete standalone Job context');
380
+ const legacy = ['--pr', '--head', '--base', '--body', '--local-merge-source', '--local-merge-target', '--idempotency-key'].find(flag => hasArg(args, flag));
381
+ if (legacy)
382
+ throw new Error(`job create does not accept ${legacy}; use job adopt only for existing PR bindings`);
383
+ }
384
+ if (action === 'adopt') {
385
+ const adoptId = arg(args, '--id');
386
+ const adoptPr = Number(arg(args, '--pr'));
387
+ if (!adoptId || !Number.isInteger(adoptPr) || adoptPr <= 0)
388
+ throw new Error('job adopt requires --id and a positive --pr');
389
+ const objective = await textOption(args, '--objective', '--objective-file');
390
+ const result = await relayControlCommand(args, { command: 'job.adopt', repository: repo, options: {
391
+ id: adoptId, prNumber: adoptPr, title: arg(args, '--title') || undefined, objective: objective || undefined, projectPath: arg(args, '--project-path') || undefined,
392
+ } });
393
+ console.log(JSON.stringify(result, null, 2));
394
+ return;
380
395
  }
381
396
  if (action === 'list') {
382
397
  console.log(JSON.stringify(await relayJobList(args), null, 2));
@@ -397,8 +412,6 @@ async function runJobCommand(action, args) {
397
412
  id: id || undefined, idempotencyKey: arg(args, '--idempotency-key') || undefined, title: arg(args, '--title') || undefined, objective: objective || undefined, projectPath: arg(args, '--project-path') || undefined,
398
413
  priority: priority(args), executionMode: requestedExecutionMode(args), schedule: scheduleFromArgs(args), tag: jobTagFromArgs(args), reviewEnabled: !hasArg(args, '--no-review'), continuation: continuation(args),
399
414
  reviewerThreadId: hasArg(args, '--reviewer-thread-id') ? arg(args, '--reviewer-thread-id') : undefined, creatorThreadId: hasArg(args, '--creator-thread-id') ? arg(args, '--creator-thread-id') : undefined, implementationThreadId: hasArg(args, '--implementation-thread-id') ? arg(args, '--implementation-thread-id') : undefined,
400
- prNumber, head: arg(args, '--head') || undefined, base: arg(args, '--base', 'main'), body: arg(args, '--body') || objective || undefined,
401
- localMergeSource: arg(args, '--local-merge-source') || undefined, localMergeTarget: arg(args, '--local-merge-target') || undefined,
402
415
  } });
403
416
  console.log(JSON.stringify(result, null, 2));
404
417
  return;
@@ -478,7 +491,25 @@ async function runJobCommand(action, args) {
478
491
  console.log(JSON.stringify(await relayControlCommand(args, { command: 'job.update', jobId: id, patch }), null, 2));
479
492
  }
480
493
  export function runtimePlanner(args) { const plannerCommand = arg([...args], '--planner-command'); const modelRuntime = arg([...args], '--codex', 'codex'); return plannerCommand ? new CommandObjectivePlanner(plannerCommand) : new AgentObjectivePlanner(modelRuntime); }
481
- export function runtime(store, args, bus, usageRegistry = codexAndZaiUsageRegistry()) { const emit = (event) => { process.stderr.write(`${JSON.stringify(event)}\n`); }; const modelRuntime = arg(args, '--codex', 'codex'); const policy = chatGptTabClosePolicy(args); const planner = runtimePlanner(args); return new Reconciler(store, { noGitHub: noGitHubMode(args), owner: arg(args, '--owner', `cli-${process.pid}`), maxConcurrent: Number(arg(args, '--concurrency', '4')), leaseMs: Number(arg(args, '--lease-ms', '300000')), adapters: [new CodexAdapter(modelRuntime), new ChatGptAdapter({ tabClosePolicy: policy })], continuations: [new CodexThreadContinuation(modelRuntime), new CommandContinuation(), new WebhookContinuation()], planner, usageSnapshotProvider: () => usageRegistry.snapshot(), decisionEngine: executionDecisionEngine(), decisionMode: decisionMode(), reviewer: runtimeReviewer(args), eventBus: bus, emit, chatgptTabClosePolicy: policy, projectPathForJob: async (job) => { const workspaceRoot = arg(args, '--workspace-root') || defaultWorkspaceRoot(); const canonical = await resolveManagedProjectPath(noGitHubMode(args) ? undefined : job.repository, arg(args, '--project-path'), workspaceRoot); return job.prNumber === 0 && job.localMerge?.sourceRef ? prepareLocalJobExecutionWorktree(canonical, job, workspaceRoot) : resolveRepositoryProjectPath(canonical, job.projectPath); } }); }
494
+ export function runtime(store, args, bus, usageRegistry = codexAndZaiUsageRegistry()) {
495
+ const emit = (event) => { process.stderr.write(`${JSON.stringify(event)}\n`); };
496
+ const modelRuntime = arg(args, '--codex', 'codex');
497
+ const policy = chatGptTabClosePolicy(args);
498
+ const planner = runtimePlanner(args);
499
+ return new Reconciler(store, { noGitHub: noGitHubMode(args), owner: arg(args, '--owner', `cli-${process.pid}`), maxConcurrent: Number(arg(args, '--concurrency', '4')), leaseMs: Number(arg(args, '--lease-ms', '300000')), adapters: [new CodexAdapter(modelRuntime), new ChatGptAdapter({ tabClosePolicy: policy })], continuations: [new CodexThreadContinuation(modelRuntime), new CommandContinuation(), new WebhookContinuation()], planner, usageSnapshotProvider: () => usageRegistry.snapshot(), decisionEngine: executionDecisionEngine(), decisionMode: decisionMode(), reviewer: runtimeReviewer(args), eventBus: bus, emit, chatgptTabClosePolicy: policy, projectPathForJob: async (job) => {
500
+ const workspaceRoot = arg(args, '--workspace-root') || defaultWorkspaceRoot();
501
+ const canonical = await resolveManagedProjectPath(noGitHubMode(args) ? undefined : job.repository, arg(args, '--project-path'), workspaceRoot);
502
+ if (!jobRequiresManagedWorktree(job))
503
+ return resolveRepositoryProjectPath(canonical, job.projectPath);
504
+ const sourceRef = await ensureLocalJobSourceBranch(canonical, job);
505
+ if (!job.localMerge?.sourceRef) {
506
+ if (!store.mutateJob)
507
+ throw new Error('Managed worktree requires atomic SQLite Job mutation');
508
+ await store.mutateJob(job.id, current => current.localMerge ? current : ({ ...current, localMerge: { sourceRef, targetRef: 'refs/heads/main', status: 'pending' }, updatedAt: new Date().toISOString() }));
509
+ }
510
+ return prepareLocalJobExecutionWorktree(canonical, await store.load(job.id), workspaceRoot);
511
+ } });
512
+ }
482
513
  export function dashboardJobAction(store, reconciler, action) {
483
514
  return async (jobId) => {
484
515
  const job = await store.load(jobId);
@@ -221,6 +221,15 @@ export class LocalAuthorityDurableStore {
221
221
  return job;
222
222
  });
223
223
  }
224
+ async createJobIfAbsent(job) {
225
+ if (this.authority.createJobIfAbsent)
226
+ return this.authority.createJobIfAbsent(job);
227
+ const existing = await this.authority.loadJob(job.id);
228
+ if (existing)
229
+ return existing;
230
+ await this.authority.saveJob(job);
231
+ return structuredClone(job);
232
+ }
224
233
  async savePlan(plan) {
225
234
  await this.mutate(plan.jobId, job => {
226
235
  const plans = [...(job.plans ?? [])];
@@ -81,11 +81,13 @@ export class GitHubProjectionWriter {
81
81
  github;
82
82
  onConflict;
83
83
  externalOnly;
84
- constructor(relay, github, onConflict, externalOnly = false) {
84
+ externalEffect;
85
+ constructor(relay, github, onConflict, externalOnly = false, externalEffect) {
85
86
  this.relay = relay;
86
87
  this.github = github;
87
88
  this.onConflict = onConflict;
88
89
  this.externalOnly = externalOnly;
90
+ this.externalEffect = externalEffect;
89
91
  }
90
92
  async flushOnce(limit = 50, now = new Date()) {
91
93
  const items = await this.relay.claimDueProjections(limit, now);
@@ -93,7 +95,7 @@ export class GitHubProjectionWriter {
93
95
  let deferred = 0;
94
96
  for (const item of items) {
95
97
  try {
96
- if (this.externalOnly && !['merge_pull_request', 'close_pull_request', 'update_pr_body'].includes(item.operation)) {
98
+ if (this.externalOnly && !['merge_pull_request', 'close_pull_request', 'update_pr_body', 'create_pull_request'].includes(item.operation)) {
97
99
  // Drop legacy lifecycle projections; GitHub may not override SQLite truth.
98
100
  await this.relay.completeProjection(item.id);
99
101
  continue;
@@ -110,6 +112,7 @@ export class GitHubProjectionWriter {
110
112
  });
111
113
  }
112
114
  await this.relay.completeProjection(item.id);
115
+ await this.relay.recordProjectionReceipt?.(item.operationKey, 'succeeded', item.attempts, { operation: item.operation });
113
116
  projected += 1;
114
117
  observability().record({
115
118
  stage: 'projection', event: 'projection.completed', outcome: 'succeeded',
@@ -135,6 +138,7 @@ export class GitHubProjectionWriter {
135
138
  else {
136
139
  const retryAt = githubProjectionRetryAt(error, item.attempts, now);
137
140
  await this.relay.retryProjection(item.id, message, retryAt);
141
+ await this.relay.recordProjectionReceipt?.(item.operationKey, 'failed', item.attempts + 1, { operation: item.operation, error: message });
138
142
  deferred += 1;
139
143
  observability().record({
140
144
  stage: 'projection', event: 'projection.retry_scheduled', level: 'warn', outcome: 'failed',
@@ -157,6 +161,12 @@ export class GitHubProjectionWriter {
157
161
  }
158
162
  }
159
163
  async apply(item) {
164
+ if (item.operation === 'create_pull_request') {
165
+ if (!this.externalEffect)
166
+ throw new Error('GitHub PR creation effect is unavailable');
167
+ await this.externalEffect(item);
168
+ return;
169
+ }
160
170
  const payload = item.payload;
161
171
  switch (payload.operation) {
162
172
  case 'save_job':
@@ -87,6 +87,15 @@ export class ProjectionOutbox {
87
87
  CREATE INDEX IF NOT EXISTS projection_coalesce_idx ON projection_outbox(coalesce_key, status);
88
88
  `;
89
89
  }
90
+ static createReceiptTableSql() {
91
+ return `CREATE TABLE IF NOT EXISTS projection_receipt (
92
+ operation_key TEXT PRIMARY KEY,
93
+ status TEXT NOT NULL CHECK(status IN ('succeeded','failed')),
94
+ attempts INTEGER NOT NULL,
95
+ detail_json TEXT,
96
+ recorded_at TEXT NOT NULL
97
+ );`;
98
+ }
90
99
  /** Rebuild a pre-v5 outbox, backfill logical operation keys, and classify legacy dead conflicts. */
91
100
  static upgrade(db, previousVersion) {
92
101
  const schema = db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='projection_outbox'").get();
@@ -220,6 +229,11 @@ export class ProjectionOutbox {
220
229
  WHERE id=?
221
230
  `).run(error, new Date().toISOString(), id);
222
231
  }
232
+ recordReceipt(operationKey, status, attempts, detail, now) {
233
+ this.db.prepare(`INSERT INTO projection_receipt(operation_key,status,attempts,detail_json,recorded_at)
234
+ VALUES(?,?,?,?,?) ON CONFLICT(operation_key) DO UPDATE SET status=excluded.status, attempts=excluded.attempts,
235
+ detail_json=excluded.detail_json, recorded_at=excluded.recorded_at`).run(operationKey, status, attempts, JSON.stringify(detail ?? null), now);
236
+ }
223
237
  recordConflict(id, diagnostic) {
224
238
  const evidence = JSON.stringify({
225
239
  kind: diagnostic.kind,
@@ -0,0 +1,18 @@
1
+ import { normalizeLegacyJobPrNumber } from './job-pr-number-migration.js';
2
+ export function createJobIfAbsent(db, job) {
3
+ db.exec('BEGIN IMMEDIATE');
4
+ try {
5
+ const row = db.prepare('SELECT json FROM job_snapshot WHERE job_id=?').get(job.id);
6
+ if (row?.json) {
7
+ db.exec('COMMIT');
8
+ return normalizeLegacyJobPrNumber(JSON.parse(row.json));
9
+ }
10
+ db.prepare('INSERT INTO job_snapshot(job_id,json,updated_at) VALUES(?,?,?)').run(job.id, JSON.stringify(job), new Date().toISOString());
11
+ db.exec('COMMIT');
12
+ return structuredClone(job);
13
+ }
14
+ catch (error) {
15
+ db.exec('ROLLBACK');
16
+ throw error;
17
+ }
18
+ }
@@ -5,6 +5,7 @@ import { mkdirSync } from 'node:fs';
5
5
  import { readdir } from 'node:fs/promises';
6
6
  import { recordPublicationInDatabase, loadPublicationFromDatabase, listPublicationEventsFromDatabase } from './publication-store.js';
7
7
  import { observability } from './observability.js';
8
+ import { createJobIfAbsent } from './relay-create.js';
8
9
  import { migrateLegacyJobPrNumbers, normalizeLegacyJobPrNumber } from './job-pr-number-migration.js';
9
10
  import { hostInvocationTablesSql, recordHostInvocationEvent, hasHostInvocationDecision, recordHostInvocationDecision } from './host-invocation-store.js';
10
11
  import { ProjectionOutbox, SCHEMA_VERSION, logicalOperationKey, } from './projection-outbox.js';
@@ -101,6 +102,7 @@ export class SQLiteRelayStore {
101
102
  );
102
103
  ${hostInvocationTablesSql}
103
104
  ${ProjectionOutbox.createTableSql()}
105
+ ${ProjectionOutbox.createReceiptTableSql()}
104
106
  ${ProjectionOutbox.createIndexSql()}
105
107
  `);
106
108
  migrateLegacyJobPrNumbers(this.db);
@@ -133,6 +135,9 @@ export class SQLiteRelayStore {
133
135
  async saveJob(job) {
134
136
  this.writeJobSnapshot(job, new Date().toISOString());
135
137
  }
138
+ async createJobIfAbsent(job) {
139
+ return createJobIfAbsent(this.db, job);
140
+ }
136
141
  /** Atomic read/modify/write for local authoritative Task and Plan updates. */
137
142
  async mutateJob(jobId, mutation) {
138
143
  this.db.exec('BEGIN IMMEDIATE');
@@ -343,13 +348,13 @@ export class SQLiteRelayStore {
343
348
  * must never reset backoff or queue a second merge while a first one runs.
344
349
  */
345
350
  async enqueueExternalActionOnce(request) {
346
- if (!['merge_pull_request', 'close_pull_request', 'update_pr_body'].includes(request.operation)) {
351
+ if (!['merge_pull_request', 'close_pull_request', 'update_pr_body', 'create_pull_request'].includes(request.operation)) {
347
352
  throw new Error('Lifecycle projection is forbidden in external-only outbox');
348
353
  }
349
354
  const key = logicalOperationKey(request.idempotencyKey);
350
355
  this.db.exec('BEGIN IMMEDIATE');
351
356
  try {
352
- const existing = this.db.prepare('SELECT id FROM projection_outbox WHERE operation_key=? LIMIT 1').get(key);
357
+ const existing = this.db.prepare('SELECT id FROM projection_outbox WHERE operation_key=? LIMIT 1').get(key) ?? this.db.prepare('SELECT operation_key FROM projection_receipt WHERE operation_key=?').get(key);
353
358
  if (existing) {
354
359
  this.db.exec('COMMIT');
355
360
  return false;
@@ -375,6 +380,7 @@ export class SQLiteRelayStore {
375
380
  async conflictProjectionCount() { return this.outbox.conflictCount(); }
376
381
  async pendingProjectionCount() { return this.outbox.pendingCount(); }
377
382
  async terminalProjectionCount() { return this.outbox.terminalCount(); }
383
+ async recordProjectionReceipt(operationKey, status, attempts, detail) { this.outbox.recordReceipt(operationKey, status, attempts, detail, new Date().toISOString()); }
378
384
  close() { this.db.close(); }
379
385
  }
380
386
  /** Canonical on-disk Relay Store location for one managed GitHub pull request. */
@@ -1,6 +1,10 @@
1
1
  import { appendTaskComment } from './task-comments.js';
2
+ import { InMemoryStore } from './store.js';
3
+ import { openLocalManagedStore } from './local-managed-store.js';
4
+ import { relayStoreDirectory } from './relay-store.js';
2
5
  import { normalizeThreadId } from './reviewer-thread.js';
3
6
  import { supersedeCancelledTask } from './task-actions.js';
7
+ import { adoptLegacyJob } from './cli-job-create.js';
4
8
  export function createWorkspaceControl(deps) {
5
9
  const { client, trusted, noGitHub, repositories, refreshAll, requireLocalJobBinding, makeReconciler, concurrency, pool, bus, isEffectivelyBlocked, unblockAutonomousTasks, unblockTask, publishTaskUnblocked, dispatchTaskAction, registerBlockerDependency, discoverAgents, ensureManagedGitHubJob } = deps;
6
10
  const workspaceControl = async (body) => {
@@ -25,31 +29,36 @@ export function createWorkspaceControl(deps) {
25
29
  tag: options.tag,
26
30
  reviewEnabled: typeof options.reviewEnabled === 'boolean' ? options.reviewEnabled : undefined,
27
31
  continuation: options.continuation,
28
- prNumber: typeof options.prNumber === 'number' ? options.prNumber : undefined,
29
- head: typeof options.head === 'string' ? options.head : undefined,
30
- base: typeof options.base === 'string' ? options.base : undefined,
31
- body: typeof options.body === 'string' ? options.body : undefined,
32
32
  reviewerThreadId: typeof options.reviewerThreadId === 'string' ? options.reviewerThreadId : undefined,
33
33
  creatorThreadId: typeof options.creatorThreadId === 'string' ? options.creatorThreadId : undefined,
34
34
  implementationThreadId: typeof options.implementationThreadId === 'string' ? options.implementationThreadId : undefined,
35
35
  noGitHub,
36
- localMergeSource: typeof options.localMergeSource === 'string' ? options.localMergeSource : undefined,
37
- localMergeTarget: typeof options.localMergeTarget === 'string' ? options.localMergeTarget : undefined,
36
+ persist: body.initialPlan !== true,
38
37
  });
39
38
  if (!repositories.includes(repository))
40
39
  repositories.push(repository);
41
40
  await refreshAll();
42
41
  if (body.initialPlan === true) {
43
- const binding = await requireLocalJobBinding(result.job.id);
44
- const current = await binding.store.load(result.job.id);
42
+ const current = result.job;
45
43
  const existingPlan = current.plans?.at(-1);
46
44
  if (existingPlan)
47
45
  return { repository, pr: result.pr.number, job: current.id, executionMode: current.executionMode, state: current.state, initialPlan: existingPlan.id, reused: true };
48
- const reconciler = makeReconciler(binding.store, concurrency);
46
+ const unpublished = new InMemoryStore(current);
47
+ const reconciler = makeReconciler(unpublished, concurrency);
49
48
  try {
50
49
  const planned = await reconciler.planJob(result.job.id);
50
+ const unpublishedStore = openLocalManagedStore(repository, 0, { directory: relayStoreDirectory(), jobId: result.job.id, noGitHub: true });
51
+ let committed = planned.job;
52
+ try {
53
+ committed = unpublishedStore.store.createJobIfAbsent
54
+ ? await unpublishedStore.store.createJobIfAbsent(planned.job)
55
+ : (await unpublishedStore.store.saveJob(planned.job), planned.job);
56
+ }
57
+ finally {
58
+ unpublishedStore.relay.close();
59
+ }
51
60
  await pool.reconcileLocalJob(result.job.id, 'control.job.create.initial-plan');
52
- return { repository, pr: result.pr.number, job: result.job.id, executionMode: result.job.executionMode, state: result.job.state, initialPlan: planned.round.id };
61
+ return { repository, pr: committed.prNumber, job: committed.id, executionMode: committed.executionMode, state: committed.state, initialPlan: committed.plans?.at(-1)?.id };
53
62
  }
54
63
  finally {
55
64
  await reconciler.close();
@@ -57,6 +66,22 @@ export function createWorkspaceControl(deps) {
57
66
  }
58
67
  return { repository, pr: result.pr.number, job: result.job.id, executionMode: result.job.executionMode, state: result.job.state };
59
68
  }
69
+ if (command === 'job.adopt') {
70
+ const repository = typeof body.repository === 'string' ? body.repository : '';
71
+ const options = (body.options && typeof body.options === 'object' ? body.options : {});
72
+ if (!repository || typeof options.id !== 'string' || typeof options.prNumber !== 'number')
73
+ throw new Error('job.adopt requires repository, id, and prNumber');
74
+ const result = await adoptLegacyJob(client, repository, trusted, {
75
+ id: options.id, title: typeof options.title === 'string' ? options.title : undefined,
76
+ objective: typeof options.objective === 'string' ? options.objective : undefined,
77
+ projectPath: typeof options.projectPath === 'string' ? options.projectPath : undefined,
78
+ prNumber: options.prNumber,
79
+ });
80
+ if (!repositories.includes(repository))
81
+ repositories.push(repository);
82
+ await refreshAll();
83
+ return { repository, pr: result.pr.number, job: result.job.id, state: result.job.state };
84
+ }
60
85
  if (!jobId)
61
86
  throw new Error(command + ' requires jobId');
62
87
  const binding = await requireLocalJobBinding(jobId);
package/dist/relayd.js CHANGED
@@ -34,7 +34,7 @@ import { applyJobActivateAction } from './job-actions.js';
34
34
  import { managedTaskUpdateHandler } from './task-update.js';
35
35
  import { dashboardJobSetState, dashboardTaskAction, dashboardTaskSetState } from './dashboard-actions.js';
36
36
  import { codexAndZaiUsageRegistry } from './usage.js';
37
- import { defaultWorkspaceRoot, discoverWorkspaceRepositories, ensureLocalJobSourceBranch, prepareLocalJobExecutionWorktree, resolveManagedProjectPath, resolveRepositoryProjectPath } from './workspace.js';
37
+ import { defaultWorkspaceRoot, discoverWorkspaceRepositories, ensureLocalJobSourceBranch, jobRequiresManagedWorktree, prepareLocalJobExecutionWorktree, resolveManagedProjectPath, resolveRepositoryProjectPath } from './workspace.js';
38
38
  import { deriveJobRuntimeStatus } from './runtime-status.js';
39
39
  import { createRuntimeStatusPublisher } from './runtime-status-events.js';
40
40
  import { subscribeLocalCommits } from './local-commit-events.js';
@@ -154,7 +154,7 @@ export async function runDaemon(argv) {
154
154
  cachedOverviews = await inventoryFromLocal();
155
155
  const makeReconciler = (store, maxConcurrent) => new Reconciler(store, { noGitHub, owner: `relayd-${process.pid}`, maxConcurrent, leaseMs: Number(value(argv, '--lease-ms', '300000')), adapters: [new CodexAdapter(value(argv, '--codex', 'codex')), new ChatGptAdapter()], continuations: [new CodexThreadContinuation(value(argv, '--codex', 'codex')), new CommandContinuation(), new WebhookContinuation()], planner: runtimePlanner(argv), usageSnapshotProvider: () => usageRegistry.snapshot(), runtimeSummaryProvider: () => ({ chatgptRunning: summarizeRuntime(cachedOverviews.map(item => item.job)).workers.chatgpt }), decisionEngine: executionDecisionEngine(), decisionMode: decisionMode(), decisionAuditStore, eventBus: bus, reviewer: runtimeReviewer(argv), projectPathForJob: async (job) => {
156
156
  const canonical = repositoryPaths.get(job.repository) ?? await resolveManagedProjectPath(job.repository, explicitProjectPath, workspaceRoot);
157
- if (!job.localMerge?.sourceRef || job.schedule)
157
+ if (!jobRequiresManagedWorktree(job))
158
158
  return resolveRepositoryProjectPath(canonical, job.projectPath);
159
159
  const sourceRef = await ensureLocalJobSourceBranch(canonical, job);
160
160
  if (!store.mutateJob)
@@ -244,62 +244,69 @@ export async function runDaemon(argv) {
244
244
  if (discovered.state === 'COMPLETED' && discovered.localMerge?.status === 'succeeded'
245
245
  && discovered.prNumber <= 0) {
246
246
  const published = projectAuthoritativeLocalMerge(canonical, discovered);
247
+ if (relay.mutateJob && (discovered.githubProjection?.status !== 'merged_without_pr' || discovered.githubProjection.sourceSha !== published.localMergedCommitSha))
248
+ await relay.mutateJob(discovered.id, current => ({ ...current, githubProjection: { status: 'merged_without_pr', sourceSha: published.localMergedCommitSha, observedAt: new Date().toISOString() } }));
247
249
  if (published.status === 'projected')
248
250
  await bus?.publish(eventFor(discovered.id, 'projection', null, 'projection.delivered', 'succeeded', `GitHub main synchronization ${published.status}`, 'orchestrator', { operation: 'github.syncMain', deliveryStatus: published.status, mergedCommitSha: published.localMergedCommitSha }, { agent: 'relay', component: 'github-projection' }));
249
251
  continue;
250
252
  }
251
253
  const existingPrNumber = discovered.prNumber > 0 ? discovered.prNumber : 0;
252
254
  const terminal = ['COMPLETED', 'CANCELLED'].includes(discovered.state);
253
- if (terminal && existingPrNumber <= 0)
254
- continue;
255
255
  const sourceSha = await gitText(canonical, ['rev-parse', '--verify', sourceRef]).catch(() => '');
256
256
  if (!sourceSha)
257
257
  continue;
258
- const facts = await relay.loadPullRequestFacts();
259
- if (facts?.head?.sha !== sourceSha) {
260
- await gitText(canonical, ['fetch', '--no-tags', 'origin',
261
- '+refs/heads/main:refs/remotes/origin/main']);
262
- await gitText(canonical, ['fetch', '--no-tags', 'origin',
263
- `+refs/heads/${branch}:refs/remotes/origin/${branch}`]).catch(() => '');
264
- const remoteRef = `refs/remotes/origin/${branch}`;
265
- const remoteHeadSha = await gitText(canonical, ['rev-parse', '--verify', remoteRef]).catch(() => '');
266
- const remoteTreeMatchesBase = remoteHeadSha
267
- ? await gitText(canonical, ['diff', '--quiet', 'refs/remotes/origin/main', remoteRef]).then(() => true, () => false)
268
- : false;
269
- await gitText(canonical, authoritativeBranchPushArgs({
270
- sourceRef,
271
- branch,
272
- remoteHeadSha: remoteHeadSha || null,
273
- remoteTreeMatchesBase,
274
- }));
275
- }
276
258
  let job = (await relay.loadJob(discovered.id)) ?? discovered;
277
259
  let prNumber = job.prNumber > 0 ? job.prNumber : 0;
278
- if (prNumber === 0 && ['COMPLETED', 'CANCELLED'].includes(job.state))
279
- continue;
280
260
  if (prNumber === 0) {
281
261
  // Do not bootstrap an empty remote-only PR commit: it would diverge from
282
262
  // the real local Job branch before its first Worker commit. Publish the
283
263
  // review-only PR when there is an actual diff against main.
284
264
  const ahead = Number(await gitText(canonical, ['rev-list', '--count', `${targetRef}..${sourceRef}`]));
285
- if (!Number.isFinite(ahead) || ahead <= 0)
265
+ if (!Number.isFinite(ahead) || ahead <= 0) {
266
+ if (terminal && relay.mutateJob && job.githubProjection?.sourceSha !== sourceSha)
267
+ await relay.mutateJob(job.id, current => ({ ...current, githubProjection: { status: current.localMerge?.status === 'succeeded' ? 'merged_without_pr' : 'no_diff', sourceSha, observedAt: new Date().toISOString() } }));
286
268
  continue;
269
+ }
287
270
  // GitHub compares origin/main, not local main. Do not invoke the legacy
288
271
  // empty-PR bootstrap path when the published base already contains the
289
272
  // source head (for example when main was pushed by another Job).
290
273
  await gitText(canonical, ['fetch', '--no-tags', 'origin', `+refs/heads/main:refs/remotes/origin/main`]);
291
274
  const remoteAhead = Number(await gitText(canonical, ['rev-list', '--count', `refs/remotes/origin/main..${sourceRef}`]));
292
- if (!Number.isFinite(remoteAhead) || remoteAhead <= 0)
275
+ if (!Number.isFinite(remoteAhead) || remoteAhead <= 0) {
276
+ if (terminal && relay.mutateJob && job.githubProjection?.sourceSha !== sourceSha)
277
+ await relay.mutateJob(job.id, current => ({ ...current, githubProjection: { status: 'merged_without_pr', sourceSha, observedAt: new Date().toISOString() } }));
293
278
  continue;
294
- const pr = await resolvePullRequest(client, job.repository, { head: branch, base, title: job.title, body: job.objective ?? job.description ?? '' });
295
- if (!relay.mutateJob)
296
- throw new Error('Atomic SQLite mutation is required for GitHub PR binding');
297
- job = await relay.mutateJob(job.id, current => ({ ...current, prNumber: pr.number, updatedAt: new Date().toISOString() }));
298
- prNumber = pr.number;
299
- observability().record({ stage: 'projection', event: 'github.pr_bound', outcome: 'succeeded', message: `Bound local Job ${job.id} to GitHub PR #${pr.number}`, correlation: { repository: job.repository, prNumber: pr.number, jobId: job.id }, data: { head: branch, base } });
279
+ }
280
+ const effect = async (item) => {
281
+ const payload = item.payload;
282
+ await gitText(canonical, ['fetch', '--no-tags', 'origin', `+refs/heads/${payload.base}:refs/remotes/origin/${payload.base}`]);
283
+ const remoteRef = `refs/remotes/origin/${payload.head}`;
284
+ const remoteHeadSha = await gitText(canonical, ['rev-parse', '--verify', remoteRef]).catch(() => '');
285
+ const remoteTreeMatchesBase = remoteHeadSha ? await gitText(canonical, ['diff', '--quiet', `refs/remotes/origin/${payload.base}`, remoteRef]).then(() => true, () => false) : false;
286
+ await gitText(canonical, authoritativeBranchPushArgs({ sourceRef: payload.sourceRef, branch: payload.head, remoteHeadSha: remoteHeadSha || null, remoteTreeMatchesBase }));
287
+ const pr = await resolvePullRequest(client, payload.repository, { head: payload.head, base: payload.base, title: payload.title, body: payload.body });
288
+ if (!relay.mutateJob)
289
+ throw new Error('Atomic SQLite mutation is required for GitHub PR binding');
290
+ await relay.mutateJob(job.id, current => ({ ...current, prNumber: pr.number, githubProjection: { status: 'pr_bound', sourceSha, observedAt: new Date().toISOString() }, updatedAt: new Date().toISOString() }));
291
+ observability().record({ stage: 'projection', event: 'github.pr_bound', outcome: 'succeeded', message: `Bound local Job ${job.id} to GitHub PR #${pr.number}`, correlation: { repository: job.repository, prNumber: pr.number, jobId: job.id }, data: { head: branch, base } });
292
+ };
293
+ projectionWriters.set(`job:${job.id}`, new GitHubProjectionWriter(relay, new InMemoryStore(job), undefined, true, effect));
294
+ if (relay.enqueueExternalActionOnce)
295
+ await relay.enqueueExternalActionOnce({ idempotencyKey: `create_pull_request:${job.id}:${sourceSha}`, operation: 'create_pull_request', payload: { operation: 'create_pull_request', repository: job.repository, head: branch, base, title: job.title, body: job.objective ?? job.description ?? '', sourceRef, targetRef }, priority: 100 });
296
+ if (terminal)
297
+ continue;
298
+ continue;
300
299
  }
301
300
  if (prNumber <= 0)
302
301
  continue;
302
+ if ((await relay.loadPullRequestFacts())?.head?.sha !== sourceSha) {
303
+ await gitText(canonical, ['fetch', '--no-tags', 'origin', '+refs/heads/main:refs/remotes/origin/main']);
304
+ const remoteRef = `refs/remotes/origin/${branch}`;
305
+ await gitText(canonical, ['fetch', '--no-tags', 'origin', `+refs/heads/${branch}:${remoteRef}`]).catch(() => '');
306
+ const remoteHeadSha = await gitText(canonical, ['rev-parse', '--verify', remoteRef]).catch(() => '');
307
+ const sameTree = remoteHeadSha ? await gitText(canonical, ['diff', '--quiet', 'refs/remotes/origin/main', remoteRef]).then(() => true, () => false) : false;
308
+ await gitText(canonical, authoritativeBranchPushArgs({ sourceRef, branch, remoteHeadSha: remoteHeadSha || null, remoteTreeMatchesBase: sameTree }));
309
+ }
303
310
  const latestFacts = await relay.loadPullRequestFacts();
304
311
  if (latestFacts?.state !== 'MERGED' && (latestFacts?.head?.sha !== sourceSha || latestFacts?.head?.ref !== branch)) {
305
312
  await relay.savePullRequestFacts({ state: latestFacts?.state === 'CLOSED' ? 'CLOSED' : 'OPEN', head: { ref: branch, sha: sourceSha }, observedAt: new Date().toISOString() });
package/dist/workspace.js CHANGED
@@ -189,6 +189,10 @@ export function managedLocalJobWorktreePath(repository, jobId, workspaceRoot = d
189
189
  const digest = createHash('sha256').update(jobId).digest('hex').slice(0, 12);
190
190
  return join(workspaceRoot, '.worktrees', 'agents-relay', `${repository.replace('/', '-')}-job-${safeJob}-${digest}`, 'job');
191
191
  }
192
+ /** Only code artifacts need an isolated Git source branch and worktree. */
193
+ export function jobRequiresManagedWorktree(job) {
194
+ return !(job.prNumber > 0) && !job.schedule && (Boolean(job.localMerge?.sourceRef) || job.tasks.some(task => task.output?.kind === 'task_pr'));
195
+ }
192
196
  /** Bind an ordinary SQLite Job to one deterministic local work branch.
193
197
  * The target is always main, and branch creation never switches the user's checkout.
194
198
  * This is a Git-local operation and never needs GitHub credentials.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-relay",
3
- "version": "2.0.92",
3
+ "version": "2.0.94",
4
4
  "description": "Durable async agent jobs coordinated through GitHub pull requests",
5
5
  "type": "module",
6
6
  "bin": {
@@ -61,7 +61,7 @@ For repository work, create/adopt/repair the PR-backed job through the Agents Re
61
61
  Example:
62
62
 
63
63
  ~~~sh
64
- npx agents-relay job create --repo OWNER/REPO --head feat/example --base main --id JOB_ID --title "Objective"
64
+ npx agents-relay job create --repo OWNER/REPO --id JOB_ID --title "Objective" --objective-file job.md --initial-plan
65
65
 
66
66
  npx agents-relay task create --repo OWNER/REPO --pr 12 --id JOB_ID --task-id implement --output task-pr --input "Implement the requested change, validate it, and update the managed task/PR."
67
67
 
@@ -15,7 +15,7 @@ For long-form content, use files instead of shell-quoted arguments:
15
15
 
16
16
  When creating a new Job from an active discussion, the orchestrator must consolidate **all context needed to finish the Job without the originating thread** into the Job objective before creation. Include, as applicable: goal, relevant background, decisions already made, requirements, constraints, lifecycle/workflow semantics, non-goals, implementation boundaries, acceptance criteria, delivery/review instructions, and any important known issues. Do not create a Job whose objective is only a one-line summary when the discussion contains material implementation context.
17
17
 
18
- For a newly created PR (`job create --head`), standalone objective Markdown is required. The PR body is the single durable Job surface: one hidden `agents-relay:job:v1` marker followed by the objective Markdown. Updating Job metadata or objective updates that same body. New Jobs must not create a separate Job comment that repeats the objective. Readers must remain backward-compatible with legacy Job-comment markers, and a successful Job save may migrate such a legacy marker into the PR body only after validating the replacement before deleting the redundant legacy Job comment.
18
+ Ordinary `job create` requires standalone objective Markdown. An external display PR is projected after the first meaningful code commit; existing PRs are adopted with `job adopt`. The PR body is the single durable Job surface: one hidden `agents-relay:job:v1` marker followed by the objective Markdown. Updating Job metadata or objective updates that same body. New Jobs must not create a separate Job comment that repeats the objective. Readers must remain backward-compatible with legacy Job-comment markers, and a successful Job save may migrate such a legacy marker into the PR body only after validating the replacement before deleting the redundant legacy Job comment.
19
19
 
20
20
  ## Task producers
21
21