agents-relay 2.0.68 → 2.0.70

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 (56) hide show
  1. package/README.md +32 -1
  2. package/dist/adapters.js +39 -3
  3. package/dist/blockers.js +17 -4
  4. package/dist/cli-blocker.js +12 -28
  5. package/dist/cli-help.js +11 -8
  6. package/dist/cli-job-create.js +19 -13
  7. package/dist/cli-local-merge-config.js +4 -2
  8. package/dist/cli-projection.js +23 -55
  9. package/dist/cli-review.js +7 -24
  10. package/dist/cli-support.js +59 -4
  11. package/dist/cli-worker-task.js +63 -0
  12. package/dist/cli.js +179 -129
  13. package/dist/dashboard-html.js +15 -12
  14. package/dist/dashboard.js +26 -1
  15. package/dist/e2e-injection.js +76 -0
  16. package/dist/github-binding-order.js +15 -0
  17. package/dist/github-client.js +7 -2
  18. package/dist/github-pr-archive.js +22 -0
  19. package/dist/github-webhook.js +31 -6
  20. package/dist/job-actions.js +29 -0
  21. package/dist/local-authority-store.js +10 -1
  22. package/dist/local-commit-events.js +2 -0
  23. package/dist/local-git-merge.js +17 -6
  24. package/dist/local-job-path.js +5 -0
  25. package/dist/local-job-registry.js +10 -4
  26. package/dist/local-managed-store.js +2 -4
  27. package/dist/local-merge-delivery.js +3 -2
  28. package/dist/local-merge-projection.js +60 -0
  29. package/dist/managed-worker-input.js +13 -6
  30. package/dist/manual-plan.js +29 -7
  31. package/dist/planner-runtime.js +182 -41
  32. package/dist/planner.js +35 -2
  33. package/dist/pool.js +22 -6
  34. package/dist/reconciler-cancellation.js +21 -0
  35. package/dist/reconciler-review.js +74 -18
  36. package/dist/reconciler-runtime.js +69 -34
  37. package/dist/reconciler.js +92 -26
  38. package/dist/relay-store.js +1 -1
  39. package/dist/relayd-control.js +184 -0
  40. package/dist/relayd-dashboard-actions.js +108 -0
  41. package/dist/relayd-utilities.js +107 -0
  42. package/dist/relayd.js +220 -210
  43. package/dist/review-generation-fence.js +12 -0
  44. package/dist/runtime-status-events.js +21 -0
  45. package/dist/runtime-status.js +13 -0
  46. package/dist/service-publication.js +2 -4
  47. package/dist/task-actions.js +59 -1
  48. package/dist/troubleshooter.js +21 -2
  49. package/dist/unblock.js +47 -15
  50. package/dist/worker-rejection.js +11 -0
  51. package/dist/workflow-effect-failures.js +30 -0
  52. package/dist/workspace.js +95 -6
  53. package/package.json +5 -4
  54. package/skills/agents-relay/agents/planner.agent.md +3 -0
  55. package/skills/agents-relay/contracts/orchestrator-workflows.md +4 -7
  56. package/workflow/review-lifecycle.mmd +3 -0
package/README.md CHANGED
@@ -188,7 +188,7 @@ Pushes to `main` run `.github/workflows/publish.yml`: install, test, choose the
188
188
 
189
189
  Normal consumers should invoke the CLI with `npx agents-relay ...` (or `npx agents-relayd ...`).
190
190
 
191
- After a successful npm publish, the workflow records one idempotent `agents-relay:package-published:v1` marker comment on the originating managed PR. The existing GitHub `issue_comment` webhook resolves that PR back to its durable Job and emits a correlated `package.published` events-bus event containing repository, package, version, registry, commit SHA, PR number, and webhook delivery ID. Its source identity is the GitHub delivery (`github/<repository>/delivery/<delivery-id>`), not a worker execution identity.
191
+ After a successful publish, the workflow records one idempotent `agents-relay:package-published:v1` marker comment using local-merge Job metadata. It uses the closed display PR when present, otherwise a reusable `[Agents Relay] Publication events` issue. The GitHub `issue_comment` webhook resolves the embedded Job ID back to its durable SQLite Job and emits a correlated `package.published` events-bus event containing repository, package, version, registry, commit SHA, PR number, and webhook delivery ID. Its source identity is the GitHub delivery (`github/<repository>/delivery/<delivery-id>`), not a worker execution identity.
192
192
 
193
193
  `package.published` is a release fact, not a service-management command. Agents Relay does not pull packages, restart PM2, or decide which local service consumes a package. An external deployment subscriber may listen for this event, verify that the exact version is pullable, update/restart the affected service, verify its running version and health, and publish its own deployment outcome event. This keeps publication, deployment, and healthy-running as separate facts and removes the need for an orchestrator thread to synchronously wait for npm readiness.
194
194
 
@@ -305,3 +305,34 @@ For autonomous Jobs, `UNBLOCKED` is historical: Relay does not retry that old Ta
305
305
  ### Browser Workspace product boundary
306
306
 
307
307
  Agents Relay owns the persistent `Relay` browser workspace as product infrastructure. At daemon startup it runs `browser-workspace status`, dynamically imports the returned absolute `admin_helper`, and calls `ensureWorkspace("Relay", 6)`. This bootstrap is best-effort: Browser Workspace discovery/helper failures are logged and skipped so they do not prevent `agents-relayd` from starting; ChatGPT browser work may remain unavailable until Browser Workspace recovers. Ordinary ChatGPT worker activity uses the public Browser Workspace one-shot `platform run ... --auto-session` command; orchestrator-owned self-healing flows may still use explicit session commands. Relay does not create or delete workspaces through CLI lifecycle commands.
308
+
309
+ ## Shared publication reporting
310
+
311
+ Repositories with an existing verified publish/release workflow can report the final result back to Agents Relay with the reusable actions in this repository:
312
+
313
+ ```yaml
314
+ permissions:
315
+ contents: read
316
+ issues: write
317
+ pull-requests: read
318
+
319
+ - name: Report verified publication
320
+ uses: lalalic/agents-relay/.github/actions/report-publication@publication-v1
321
+ with:
322
+ token: ${{ github.token }}
323
+ artifact: my-package-or-artifact
324
+ version: ${{ steps.release.outputs.version }}
325
+ channel: https://registry.npmjs.org
326
+
327
+ - name: Report release failure
328
+ if: ${{ failure() }}
329
+ continue-on-error: true
330
+ uses: lalalic/agents-relay/.github/actions/report-publication-failure@publication-v1
331
+ with:
332
+ token: ${{ github.token }}
333
+ stage: release workflow
334
+ ```
335
+
336
+ The caller must report only a release it has actually verified. For local-first Jobs, the action reads the Job ID and optional display PR from the authoritative local-merge commit message and posts the compatible `agents-relay:package-published:v1` or publish-failure marker to that display PR or to a reusable publication issue. Historical GitHub-merged PR correlation remains supported as a fallback. The marker and GitHub webhook are transport/correlation only: Agents Relay resolves the originating Job and persists publication facts through `RelayStore.recordPublication(...)` into the authoritative SQLite `publication_delivery` and `publication_state` tables. Publication success or failure never changes a completed Job's lifecycle state.
337
+
338
+ `artifact` can identify an npm/PyPI package, Chrome extension, GitHub Release artifact, or a custom published artifact. `channel` identifies the registry/store/release channel and is displayed as publication metadata. Repeated reports of the same artifact/version are idempotent.
package/dist/adapters.js CHANGED
@@ -2,6 +2,8 @@ import { readFile, writeFile, unlink } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
3
  import { pathToFileURL } from 'node:url';
4
4
  import { randomUUID } from 'node:crypto';
5
+ import { AsyncLocalStorage } from 'node:async_hooks';
6
+ import { withTestInjection } from './e2e-injection.js';
5
7
  import { spawnOwnedProcess, terminateOwnedProcess, ownedProcessTerminationReason } from './owned-process.js';
6
8
  export function buildCodexArgs(route, input) {
7
9
  const args = ['exec', '--json'];
@@ -27,6 +29,21 @@ function parseThreadStarted(line) {
27
29
  return undefined;
28
30
  }
29
31
  }
32
+ function parseCodexFailure(line) {
33
+ try {
34
+ const value = JSON.parse(line);
35
+ if (value.type === 'turn.failed' && value.error && typeof value.error === 'object' && !Array.isArray(value.error)) {
36
+ const message = value.error.message;
37
+ if (typeof message === 'string' && message.trim())
38
+ return { message: message.trim(), priority: 2 };
39
+ }
40
+ if (value.type === 'error' && typeof value.message === 'string' && value.message.trim()) {
41
+ return { message: value.message.trim(), priority: 1 };
42
+ }
43
+ }
44
+ catch { /* non-JSON output is not structured Codex failure evidence */ }
45
+ return undefined;
46
+ }
30
47
  function appendTail(current, chunk, maxBytes = 16384) {
31
48
  const combined = current + chunk;
32
49
  return Buffer.byteLength(combined) <= maxBytes
@@ -42,6 +59,8 @@ export class CodexAdapter {
42
59
  this.command = command;
43
60
  }
44
61
  launch(task, signal, context = {}) {
62
+ if (context.testInjection?.method === 'worker.codex.launch')
63
+ return withTestInjection('worker.codex.launch', context.testInjection, () => this.launch(task, signal, { ...context, testInjection: undefined }));
45
64
  if (!task.routing)
46
65
  throw new Error(`Task ${task.id} requires routing metadata before Codex launch`);
47
66
  const child = spawnOwnedProcess(this.command, buildCodexArgs(task.routing, task.input), {
@@ -52,6 +71,7 @@ export class CodexAdapter {
52
71
  });
53
72
  let stdoutBuffer = '';
54
73
  let stderrTail = '';
74
+ let structuredFailure;
55
75
  let bufferedThreadId;
56
76
  let launchedResolve;
57
77
  let launchedReject;
@@ -59,6 +79,9 @@ export class CodexAdapter {
59
79
  launched.catch(() => { });
60
80
  let execution;
61
81
  const consumeLine = (line) => {
82
+ const failure = parseCodexFailure(line);
83
+ if (failure && (!structuredFailure || failure.priority >= structuredFailure.priority))
84
+ structuredFailure = failure;
62
85
  const threadId = parseThreadStarted(line);
63
86
  if (!threadId)
64
87
  return;
@@ -89,7 +112,7 @@ export class CodexAdapter {
89
112
  resolve({ summary: 'Codex runtime exited after task execution; terminal task state is event-owned' });
90
113
  }
91
114
  else {
92
- reject(new Error(stderrTail.trim() || `Codex exited ${code}`));
115
+ reject(new Error(structuredFailure?.message || stderrTail.trim() || `Codex exited ${code}`));
93
116
  }
94
117
  });
95
118
  });
@@ -148,7 +171,11 @@ export function parseDriverJson(output) {
148
171
  function browserWorkspaceCli(override = process.env.AGENTS_RELAY_BROWSER_WORKSPACE_COMMAND) {
149
172
  return override ? { command: override, prefix: [] } : { command: 'uvx', prefix: ['browser-workspace'] };
150
173
  }
174
+ const browserWorkspaceInjection = new AsyncLocalStorage();
151
175
  function browserWorkspaceCommand(cli, args, stdin, signal) {
176
+ return Promise.resolve(withTestInjection('browserWorkspace.command', browserWorkspaceInjection.getStore(), () => rawBrowserWorkspaceCommand(cli, args, stdin, signal)));
177
+ }
178
+ function rawBrowserWorkspaceCommand(cli, args, stdin, signal) {
152
179
  return new Promise((resolve, reject) => {
153
180
  const child = spawnOwnedProcess(cli.command, [...cli.prefix, ...args], {
154
181
  invocation: 'browser-workspace.command',
@@ -344,14 +371,23 @@ export class ChatGptAdapter {
344
371
  if (controller.signal.aborted)
345
372
  throw new Error('browser-worker execution aborted');
346
373
  const policy = chatGptTabClosePolicy(task.chatgptTabClosePolicy, this.tabClosePolicy);
347
- const run = (this.runner ?? browserWorkerRunner())(definition, {
374
+ const submit = () => (this.runner ?? browserWorkerRunner())(definition, {
348
375
  prompt: task.input,
349
376
  ...(task.attachments?.length ? { files: task.attachments } : {}),
350
377
  ...(task.chatgptThreadDelivery ? { thread: task.chatgptThreadDelivery } : {}),
351
378
  tabClosePolicy: policy,
352
379
  }, route, controller.signal);
380
+ const run = context.testInjection?.method === 'browserWorkspace.command'
381
+ ? browserWorkspaceInjection.run(context.testInjection, submit) : submit();
353
382
  try {
354
- const output = await (run.submitted ?? run.promise);
383
+ // Start acknowledgement may expire while browser submission is still pending.
384
+ // Race its rejection with delivery so a hung browser cannot hold a QUEUED
385
+ // execution until the full lease expires. A successful ack never shortcuts delivery.
386
+ const submission = run.submitted ?? run.promise;
387
+ const startAckFailure = context.waitForWorkerStart?.then(() => new Promise(() => { }), error => Promise.reject(error));
388
+ const output = await (startAckFailure
389
+ ? Promise.race([submission, startAckFailure])
390
+ : submission);
355
391
  const response = parseBrowserWorkerResponse(output);
356
392
  if (task.chatgptThreadDelivery && (typeof response.thread_id !== 'string' || response.thread_id !== task.chatgptThreadDelivery.threadId)) {
357
393
  throw new Error('browser-worker submitted to an unexpected ChatGPT thread');
package/dist/blockers.js CHANGED
@@ -38,10 +38,23 @@ export async function registerBlockerDependency(options) {
38
38
  }
39
39
  if (blocker.blockerResolutionMode && blocker.blockerResolutionMode !== requestedMode)
40
40
  throw new Error(`Blocker ${blocker.id} already uses ${blocker.blockerResolutionMode} resolution`);
41
- blocker.blockerResolutionMode = requestedMode;
42
- blocker.unblocks = [...(blocker.unblocks ?? []), relation];
43
- blocker.updatedAt = now;
44
- await options.blockerStore.saveJob(blocker);
41
+ if (options.blockerStore.mutateJob) {
42
+ await options.blockerStore.mutateJob(blocker.id, current => {
43
+ if (current.blockerResolutionMode && current.blockerResolutionMode !== requestedMode)
44
+ throw new Error(`Blocker ${current.id} already uses ${current.blockerResolutionMode} resolution`);
45
+ current.blockerResolutionMode = requestedMode;
46
+ if (!(current.unblocks ?? []).some(item => dependentKey(item) === dependentKey(relation)))
47
+ current.unblocks = [...(current.unblocks ?? []), relation];
48
+ current.updatedAt = now;
49
+ return current;
50
+ });
51
+ }
52
+ else {
53
+ blocker.blockerResolutionMode = requestedMode;
54
+ blocker.unblocks = [...(blocker.unblocks ?? []), relation];
55
+ blocker.updatedAt = now;
56
+ await options.blockerStore.saveJob(blocker);
57
+ }
45
58
  task.blockedBy = taskBlocker;
46
59
  task.updatedAt = now;
47
60
  await options.dependentStore.saveTask(task);
@@ -1,43 +1,27 @@
1
- import { openLocalManagedStore } from './local-managed-store.js';
2
- import { registerBlockerDependency } from './blockers.js';
3
- import { arg, publishDependentUnblocked, repository, withEventBus } from './cli-support.js';
1
+ import { arg, relayControlCommand, relayControlJob, repository } from './cli-support.js';
4
2
  import { usage } from './cli-help.js';
5
3
  export async function runBlockerCommand(action, args) {
6
4
  if (!['add', 'resolve', 'show'].includes(action))
7
5
  throw new Error(usage);
8
- const repo = repository(args);
6
+ repository(args);
9
7
  const id = arg(args, '--id');
10
- const pr = Number(arg(args, '--pr'));
11
- if (!id || !Number.isInteger(pr) || pr <= 0)
12
- throw new Error('blocker commands require --repo, --pr, and --id for the blocker Job');
13
- const blockerStore = openLocalManagedStore(repo, pr).store;
8
+ if (!id)
9
+ throw new Error('blocker commands require --repo and --id for the blocker Job');
14
10
  if (action === 'show') {
15
- console.log(JSON.stringify(await blockerStore.load(id), null, 2));
11
+ console.log(JSON.stringify(await relayControlJob(args, id), null, 2));
16
12
  return;
17
13
  }
18
14
  if (action === 'resolve') {
19
- const summary = await blockerStore.resolveBlockerDependents(id, 'verified');
20
- await withEventBus(args, bus => publishDependentUnblocked(bus, summary.unblocked, `Blocker Job ${id} resolved (verified)`));
21
- console.log(JSON.stringify({ blocker: id, resolution: 'verified', ...summary }, null, 2));
15
+ const result = await relayControlCommand(args, { command: 'blocker.resolve', jobId: id, resolution: 'verified' });
16
+ console.log(JSON.stringify({ blocker: id, resolution: 'verified', ...result }, null, 2));
22
17
  return;
23
18
  }
24
- const dependentRepo = arg(args, '--dependent-repo');
25
- const dependentPr = Number(arg(args, '--dependent-pr'));
26
- const dependentJob = arg(args, '--dependent-job');
27
- const dependentTask = arg(args, '--dependent-task');
28
- if (!dependentRepo || !Number.isInteger(dependentPr) || dependentPr <= 0 || !dependentJob || !dependentTask) {
29
- throw new Error('blocker add requires --dependent-repo, --dependent-pr, --dependent-job, and --dependent-task');
30
- }
19
+ const dependentJobId = arg(args, '--dependent-job');
20
+ const dependentTaskId = arg(args, '--dependent-task');
21
+ if (!dependentJobId || !dependentTaskId)
22
+ throw new Error('blocker add requires --dependent-job and --dependent-task');
31
23
  const modeValue = arg(args, '--resolution', 'merge');
32
24
  if (!['merge', 'verified'].includes(modeValue))
33
25
  throw new Error('--resolution must be merge or verified');
34
- const relation = await registerBlockerDependency({
35
- blockerStore,
36
- blockerJobId: id,
37
- dependentStore: openLocalManagedStore(dependentRepo, dependentPr).store,
38
- dependentJobId: dependentJob,
39
- dependentTaskId: dependentTask,
40
- resolutionMode: modeValue,
41
- });
42
- console.log(JSON.stringify(relation, null, 2));
26
+ console.log(JSON.stringify(await relayControlCommand(args, { command: 'blocker.add', jobId: id, dependentJobId, dependentTaskId, resolutionMode: modeValue }), null, 2));
43
27
  }
package/dist/cli-help.js CHANGED
@@ -59,12 +59,12 @@ Optional: --thread-id THREAD_ID persists a reviewer override; omitted uses Job r
59
59
  'job create': `Create a SQLite-authoritative Job.
60
60
 
61
61
  Required: --repo OWNER/REPO, --id JOB_ID, and either --head BRANCH or --pr NUMBER.
62
- With --head, reuse/create the PR for that head. With --pr, bind an external PR reference locally without a GitHub query.
63
- Common: --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, --no-review, --tag JSON, --reviewer-thread-id ID, --creator-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 and never creates executable tasks. The orchestrator decides the initial task batch separately. Re-running create against an already healthy Job is idempotent and does not mutate its metadata.
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.
63
+ Common: --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. --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.
65
65
 
66
66
  Example:
67
- npx agents-relay job create --repo OWNER/REPO --head feat/example --base main --id job-1 --title "Objective" --objective-file job.md`,
67
+ npx agents-relay job create --repo OWNER/REPO --head feat/example --base main --id job-1 --title "Objective" --objective-file job.md --initial-plan`,
68
68
  'job update': `Partially update mutable metadata on a healthy existing Job.
69
69
 
70
70
  Required: --repo OWNER/REPO, --pr NUMBER, and --id JOB_ID.
@@ -144,6 +144,9 @@ Required: --task-id ID, --summary TEXT, and the normal Job storage options. The
144
144
  'task retry': `Retry one durable Task.
145
145
 
146
146
  Required: --task-id ID and the normal Job storage options.`,
147
+ 'task recover': `Retry one failed/blocked durable Task from its active automatic Troubleshooter without cancelling that recovery Task.
148
+
149
+ Required: --task-id ID, --recovery-task-id TROUBLESHOOTER_ID, and the normal Job storage options. The recovery Task must be an active Troubleshooter whose parentTaskId is the original Task.`,
147
150
  'task cancel': `Cancel one durable Task.
148
151
 
149
152
  Required: --task-id ID and the normal Job storage options.`,
@@ -216,7 +219,7 @@ Example:
216
219
  npx agents-relay serve --repo OWNER/REPO --pr 12 --id job-1`,
217
220
  'agent-register': `Register an agent role and its execution endpoint.
218
221
 
219
- Required: --file PATH or managed GitHub options, plus --name NAME --responsibility TEXT --role ROLE --endpoint TARGET.
222
+ Required for durable service mode: --repo OWNER/REPO --id JOB_ID plus --name NAME --responsibility TEXT --role ROLE --endpoint TARGET. Use --file only for explicit demo mode.
220
223
  Common: --capabilities a,b, --runtime shell|codex|chatgpt, --provider NAME, --model NAME.
221
224
 
222
225
  Example:
@@ -224,8 +227,8 @@ Example:
224
227
  projection: `Inspect and explicitly repair deterministic V2 GitHub projection conflicts.
225
228
 
226
229
  Usage:
227
- npx agents-relay projection conflicts --repo OWNER/REPO --pr NUMBER [--store PATH]
228
- npx agents-relay projection resolve --repo OWNER/REPO --pr NUMBER --projection-id ID --action requeue|discard-local
230
+ npx agents-relay projection conflicts --repo OWNER/REPO --id JOB_ID
231
+ npx agents-relay projection resolve --repo OWNER/REPO --id JOB_ID --projection-id ID --action requeue|discard-local
229
232
 
230
233
  A deterministic same-id/different-definition append conflict is classified once, stops
231
234
  consuming retry budget, and retains structured diagnostic evidence. It is never
@@ -234,7 +237,7 @@ auto-overwritten: an operator resolves it after reconciling the divergent defini
234
237
  discard-local Drop the blocked local mutation so a fresh one can be enqueued`,
235
238
  'agent-discover': `Find registered agents using hard filters.
236
239
 
237
- Required: --file PATH or managed GitHub options.
240
+ Required for durable service mode: --repo OWNER/REPO --id JOB_ID. Use --file only for explicit demo mode.
238
241
  Common: --capabilities a,b, --runtime NAME, --availability available,busy, --task-kind NAME, --explore.
239
242
 
240
243
  Example:
@@ -1,31 +1,31 @@
1
1
  import { openLocalManagedStore } from './local-managed-store.js';
2
2
  import { createJobEntity } from './workflow/entity-creation.js';
3
3
  import { normalizeThreadId } from './reviewer-thread.js';
4
- import { discoverLocalRelayStoreSnapshots } from './relay-store.js';
5
- import { homedir } from 'node:os';
6
- import { join } from 'node:path';
4
+ import { discoverLocalRelayStoreSnapshots, relayStoreDirectory } from './relay-store.js';
7
5
  /**
8
- * Job lifecycle bootstrap: SQLite is authoritative. PR creation is an
9
- * optional one-time external adapter; an explicit PR number is offline-safe.
6
+ * Job lifecycle bootstrap: SQLite is authoritative. No GitHub request is
7
+ * required; an explicit PR number is optional external metadata.
10
8
  */
11
9
  export async function ensureManagedGitHubJob(_client, repositoryName, _trustedAuthors, options) {
12
10
  if (!options.id)
13
11
  throw new Error('--id is required for managed local Jobs');
14
- const number = options.prNumber ?? 0;
15
- if (!Number.isInteger(number) || number < 0)
12
+ const requestedNumber = options.prNumber ?? 0;
13
+ if (!Number.isInteger(requestedNumber) || requestedNumber < 0)
16
14
  throw new Error('Invalid external PR binding number');
17
- const pr = { number, title: options.title ?? options.id,
18
- body: options.body ?? options.objective ?? '', state: 'OPEN', head: '', base: options.base ?? '' };
19
- const directory = options.storeDirectory ?? join(homedir(), '.agents-relay', 'stores');
15
+ const placeholder = { number: requestedNumber, title: options.title ?? options.id,
16
+ body: options.body ?? options.objective ?? '', state: 'OPEN', head: options.head ?? '', base: options.base ?? '' };
17
+ const directory = options.storeDirectory ?? relayStoreDirectory();
20
18
  const alreadyKnown = (await discoverLocalRelayStoreSnapshots(directory)).flatMap(snapshot => snapshot.jobs.filter(job => job.id === options.id).map(job => ({ job, path: snapshot.path })));
21
19
  if (alreadyKnown.length > 1)
22
20
  throw new Error('Duplicate immutable Job ID across SQLite stores: ' + options.id);
23
21
  if (alreadyKnown.length === 1) {
24
22
  const existing = alreadyKnown[0].job;
25
- if (existing.repository !== repositoryName || (options.prNumber !== undefined && existing.prNumber !== number))
23
+ if (existing.repository !== repositoryName || (options.prNumber !== undefined && existing.prNumber !== requestedNumber))
26
24
  throw new Error('Immutable Job ID is already bound to a different repository or external PR');
27
- return { job: existing, pr: { ...pr, number: existing.prNumber } };
25
+ return { job: existing, pr: { ...placeholder, number: existing.prNumber } };
28
26
  }
27
+ const pr = placeholder;
28
+ const number = pr.number;
29
29
  const local = openLocalManagedStore(repositoryName, number, {
30
30
  directory, jobId: options.id, noGitHub: true,
31
31
  });
@@ -34,6 +34,12 @@ export async function ensureManagedGitHubJob(_client, repositoryName, _trustedAu
34
34
  if (existing)
35
35
  return { job: existing, pr };
36
36
  const now = new Date().toISOString();
37
+ const localMergeSource = options.localMergeSource ?? (options.head ? `refs/heads/${options.head}` : undefined);
38
+ if (options.localMergeTarget && options.localMergeTarget !== 'refs/heads/main')
39
+ throw new Error('Local merge target must always be main');
40
+ if (options.base && options.base !== 'main')
41
+ throw new Error('Job base must always be main');
42
+ const localMergeTarget = localMergeSource ? 'refs/heads/main' : undefined;
37
43
  const job = createJobEntity({
38
44
  id: options.id,
39
45
  title: options.title || pr.title || 'Agents Relay job',
@@ -44,7 +50,7 @@ export async function ensureManagedGitHubJob(_client, repositoryName, _trustedAu
44
50
  executionMode: options.executionMode ?? 'fixed',
45
51
  tag: options.tag,
46
52
  autoMerge: true,
47
- ...(options.localMergeSource && options.localMergeTarget ? { localMerge: { sourceRef: options.localMergeSource, targetRef: options.localMergeTarget, status: 'pending' } } : {}),
53
+ ...(localMergeSource && localMergeTarget ? { localMerge: { sourceRef: localMergeSource, targetRef: localMergeTarget, status: 'pending' } } : {}),
48
54
  reviewEnabled: options.reviewEnabled ?? true,
49
55
  ...(options.reviewerThreadId
50
56
  ? { reviewerThreadId: normalizeThreadId(options.reviewerThreadId, 'reviewer thread ID') }
@@ -1,8 +1,10 @@
1
1
  export function configureLocalGitMerge(job, source, target, localOnly) {
2
2
  // The GitHub integration is observational; local merge applies in either mode.
3
3
  void localOnly;
4
- if (!source || !target)
5
- throw new Error('Supply both --local-merge-source and --local-merge-target');
4
+ if (!source)
5
+ throw new Error('Local merge source is required');
6
+ if (target !== 'refs/heads/main')
7
+ throw new Error('Local Git merge target must always be refs/heads/main');
6
8
  if (!['ACTIVE', 'DRAFT'].includes(job.state))
7
9
  throw new Error('Cannot reconfigure merge of a terminal Job');
8
10
  job.localMerge = { sourceRef: source, targetRef: target, status: 'pending' };
@@ -1,61 +1,29 @@
1
- import { homedir } from 'node:os';
2
- import { join } from 'node:path';
3
- import { SQLiteRelayStore, relayStorePath } from './relay-store.js';
4
- import { arg, integerArg, pullRequest, repository } from './cli-support.js';
5
- const DEFAULT_STORE_DIR = join(homedir(), '.agents-relay', 'stores');
6
- function storePath(args) {
7
- const explicit = arg(args, '--store');
8
- if (explicit)
9
- return explicit;
10
- return relayStorePath(arg(args, '--store-dir', DEFAULT_STORE_DIR), repository(args), pullRequest(args));
11
- }
1
+ import { arg, integerArg, relayControlCommand } from './cli-support.js';
12
2
  export async function runProjectionCommand(action, args) {
13
- const store = new SQLiteRelayStore(storePath(args));
14
- try {
15
- if (action === 'conflicts' || action === 'list') {
16
- const conflicts = await store.listProjectionConflicts();
17
- console.log(JSON.stringify(conflicts, null, 2));
18
- return;
19
- }
20
- if (action === 'resolve') {
21
- const id = integerArg(args, '--projection-id', 0);
22
- if (id <= 0)
23
- throw new Error('projection resolve requires --projection-id ID');
24
- const raw = arg(args, '--action', 'requeue');
25
- if (raw !== 'requeue' && raw !== 'discard-local' && raw !== 'discard_local')
26
- throw new Error('--action must be requeue or discard-local');
27
- const resolution = raw === 'requeue' ? 'requeue' : 'discard_local';
28
- const note = arg(args, '--note') || undefined;
29
- const resolved = await store.resolveProjectionConflict(id, { action: resolution, ...(note ? { note } : {}) });
30
- if (!resolved)
31
- throw new Error(`Projection ${id} is not in a resolvable conflict state`);
32
- console.log(`Resolved projection ${id} with ${resolution}`);
33
- return;
34
- }
35
- throw new Error(`projection ${action || '<action>'} is not supported; use conflicts or resolve`);
3
+ const jobId = arg(args, '--id');
4
+ if (!jobId)
5
+ throw new Error('projection commands require --id JOB_ID');
6
+ if (action === 'conflicts' || action === 'list') {
7
+ console.log(JSON.stringify(await relayControlCommand(args, { command: 'projection.conflicts', jobId }), null, 2));
8
+ return;
36
9
  }
37
- finally {
38
- store.close();
10
+ if (action === 'resolve') {
11
+ const id = integerArg(args, '--projection-id', 0);
12
+ if (id <= 0)
13
+ throw new Error('projection resolve requires --projection-id ID');
14
+ const raw = arg(args, '--action', 'requeue');
15
+ if (!['requeue', 'discard-local', 'discard_local'].includes(raw))
16
+ throw new Error('--action must be requeue or discard-local');
17
+ const resolution = raw === 'requeue' ? 'requeue' : 'discard_local';
18
+ const note = arg(args, '--note') || undefined;
19
+ const result = await relayControlCommand(args, { command: 'projection.resolve', jobId, projectionId: id, action: resolution, ...(note ? { note } : {}) });
20
+ console.log(JSON.stringify(result, null, 2));
21
+ return;
39
22
  }
23
+ throw new Error(`projection ${action || '<action>'} is not supported; use conflicts or resolve`);
40
24
  }
41
25
  export function projectionHelp(action) {
42
- if (action === 'resolve') {
43
- return `Explicitly repair one classified projection conflict.
44
-
45
- Required: --projection-id ID --action requeue|discard-local, plus --store PATH or --repo OWNER/REPO --pr NUMBER.
46
- Actions:
47
- requeue Re-arm the recorded projection after the divergent side is reconciled
48
- discard-local Drop the blocked local mutation so a fresh one can be enqueued
49
-
50
- Never silently overwrites either side. The recorded diagnostic remains until resolution.`;
51
- }
52
- return `Inspect and explicitly repair deterministic V2 GitHub projection conflicts.
53
-
54
- Usage:
55
- npx agents-relay projection conflicts --repo OWNER/REPO --pr NUMBER [--store PATH]
56
- npx agents-relay projection resolve --repo OWNER/REPO --pr NUMBER --projection-id ID --action requeue|discard-local
57
-
58
- A deterministic same-id/different-definition append conflict is classified once, stops
59
- consuming retry budget, and retains structured diagnostic evidence. It is never
60
- auto-overwritten: an operator resolves it after reconciling the divergent definition.`;
26
+ if (action === 'resolve')
27
+ return `Explicitly repair one classified projection conflict.\n\nRequired: --repo OWNER/REPO --id JOB_ID --projection-id ID --action requeue|discard-local.`;
28
+ return `Inspect and explicitly repair deterministic V2 GitHub projection conflicts.\n\nUsage:\n npx agents-relay projection conflicts --repo OWNER/REPO --id JOB_ID\n npx agents-relay projection resolve --repo OWNER/REPO --id JOB_ID --projection-id ID --action requeue|discard-local`;
61
29
  }
@@ -1,5 +1,5 @@
1
- import { storeFor, arg, hasArg, withEventBus, requestManagedReconciliation } from './cli-support.js';
2
- import { normalizeThreadId, resolveReviewerThreadDestination } from './reviewer-thread.js';
1
+ import { arg, hasArg, relayControlCommand } from './cli-support.js';
2
+ import { normalizeThreadId } from './reviewer-thread.js';
3
3
  export function reviewRequestThreadOverride(args) {
4
4
  const values = [...args];
5
5
  return hasArg(values, '--thread-id')
@@ -9,27 +9,10 @@ export function reviewRequestThreadOverride(args) {
9
9
  export async function runReviewCommand(action, reviewArgs) {
10
10
  if (action !== 'request')
11
11
  throw new Error('review supports: request');
12
- if (!arg(reviewArgs, '--repo') || !arg(reviewArgs, '--pr') || !arg(reviewArgs, '--id'))
13
- throw new Error('review request requires --repo, --pr and --id');
12
+ const jobId = arg(reviewArgs, '--id');
13
+ if (!arg(reviewArgs, '--repo') || !jobId)
14
+ throw new Error('review request requires --repo and --id');
14
15
  const threadId = reviewRequestThreadOverride(reviewArgs);
15
- const { store, job, localFile } = await storeFor(reviewArgs);
16
- if (localFile)
17
- throw new Error('review request requires a managed GitHub Job');
18
- if (job.state === 'CANCELLED' || job.state === 'COMPLETED')
19
- throw new Error('Cannot request review for terminal Job');
20
- if (!job.reviewEnabled)
21
- throw new Error('Review disabled for this Job');
22
- if (job.tasks.some(task => task.agentName === 'reviewer' && ['RUNNING', 'QUEUED', 'DRAFT'].includes(task.state)))
23
- throw new Error('Review already queued or running; refusing duplicate request');
24
- if (job.review?.outcome === 'approved')
25
- throw new Error('Current head already approved; review request not needed');
26
- if (threadId !== undefined) {
27
- job.reviewerThreadId = threadId;
28
- job.updatedAt = new Date().toISOString();
29
- await store.saveJob(job);
30
- }
31
- const destination = resolveReviewerThreadDestination(job);
32
- await withEventBus(reviewArgs, async (bus) => requestManagedReconciliation(bus, job));
33
- console.log(JSON.stringify({ jobId: job.id, pr: job.prNumber, reviewerThreadId: destination?.threadId ?? null, status: 'requested', note: 'Relay graph will create the review Task once eligible; this is not a delivery receipt.' }, null, 2));
34
- return;
16
+ const job = await relayControlCommand(reviewArgs, { command: 'review.request', jobId, ...(threadId !== undefined ? { threadId } : {}) });
17
+ console.log(JSON.stringify({ jobId: job.id, pr: job.externalPrNumber ?? job.prNumber ?? 0, reviewerThreadId: job.reviewerThreadId ?? null, status: 'requested', note: 'Relay graph will create the review Task once eligible; this is not a delivery receipt.' }, null, 2));
35
18
  }
@@ -4,8 +4,7 @@ import { githubAuthContext } from './github-auth.js';
4
4
  import { noGitHubMode } from './no-github.js';
5
5
  import { basename } from 'node:path';
6
6
  import { createJobIdRegistryResolver } from './local-job-registry.js';
7
- import { homedir } from 'node:os';
8
- import { join } from 'node:path';
7
+ import { relayStoreDirectory } from './relay-store.js';
9
8
  import { NatsEventBus, eventFor } from './events.js';
10
9
  import { parseJobSchedule } from './scheduler.js';
11
10
  import { parseJobTagJson } from './task-tag.js';
@@ -73,12 +72,13 @@ export async function storeFor(args) {
73
72
  const jobId = arg(args, '--id');
74
73
  if (!repo || !jobId)
75
74
  throw new Error('Local Job requires --repo and --id (or explicit --file)');
76
- const directory = arg(args, '--store-dir') || join(homedir(), '.agents-relay', 'stores');
75
+ const directory = arg(args, '--store-dir') || relayStoreDirectory();
77
76
  const binding = await createJobIdRegistryResolver(directory, new Map())(jobId);
78
77
  if (!binding || binding.job.repository !== repo)
79
78
  throw new Error('Immutable local Job not found for repository: ' + jobId);
80
79
  const suppliedPr = arg(args, '--pr');
81
- if (suppliedPr && binding.job.prNumber !== Number(suppliedPr))
80
+ const boundPr = binding.job.externalPrNumber ?? (binding.job.prNumber > 0 ? binding.job.prNumber : 0);
81
+ if (suppliedPr && boundPr !== Number(suppliedPr))
82
82
  throw new Error('Local Job external PR binding mismatch');
83
83
  return { store: binding.store, job: await binding.store.load(jobId), localFile: null };
84
84
  }
@@ -107,6 +107,61 @@ export async function relayControlJob(args, jobId) {
107
107
  throw new Error(`Agents Relay job request failed: ${await response.text() || `HTTP ${response.status}`}`);
108
108
  return await response.json();
109
109
  }
110
+ export async function relayControlCommand(args, body) {
111
+ const host = arg(args, '--host', '127.0.0.1');
112
+ const port = Number(arg(args, '--port', String(SERVICE_DEFAULTS.port)));
113
+ if (!Number.isInteger(port) || port <= 0 || port > 65535)
114
+ throw new Error('--port must be an integer between 1 and 65535');
115
+ let response;
116
+ try {
117
+ response = await fetch(`http://${host}:${port}/api/control`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
118
+ }
119
+ catch (error) {
120
+ throw new Error(`Agents Relay service unavailable at ${host}:${port}: ${error instanceof Error ? error.message : String(error)}`);
121
+ }
122
+ if (!response.ok)
123
+ throw new Error(`Agents Relay control command failed: ${await response.text() || `HTTP ${response.status}`}`);
124
+ return await response.json();
125
+ }
126
+ export async function relayJobList(args) {
127
+ const host = arg(args, '--host', '127.0.0.1');
128
+ const port = Number(arg(args, '--port', String(SERVICE_DEFAULTS.port)));
129
+ const repo = arg(args, '--repo');
130
+ let response;
131
+ try {
132
+ response = await fetch(`http://${host}:${port}/api/jobs`);
133
+ }
134
+ catch (error) {
135
+ throw new Error(`Agents Relay service unavailable at ${host}:${port}: ${error instanceof Error ? error.message : String(error)}`);
136
+ }
137
+ if (!response.ok)
138
+ throw new Error(`Agents Relay jobs request failed: ${await response.text() || `HTTP ${response.status}`}`);
139
+ const jobs = await response.json();
140
+ return repo ? jobs.filter(job => job.repository === repo) : jobs;
141
+ }
142
+ export async function relayJobStateCommand(args, jobId, state) {
143
+ const host = arg(args, '--host', '127.0.0.1');
144
+ const port = Number(arg(args, '--port', String(SERVICE_DEFAULTS.port)));
145
+ if (!Number.isInteger(port) || port <= 0 || port > 65535)
146
+ throw new Error('--port must be an integer between 1 and 65535');
147
+ const params = new URLSearchParams({ id: jobId, state });
148
+ const repo = arg(args, '--repo');
149
+ const pr = Number(arg(args, '--pr'));
150
+ if (repo)
151
+ params.set('repo', repo);
152
+ if (Number.isInteger(pr) && pr > 0)
153
+ params.set('pr', String(pr));
154
+ let response;
155
+ try {
156
+ response = await fetch(`http://${host}:${port}/api/job-state?${params.toString()}`, { method: 'POST' });
157
+ }
158
+ catch (error) {
159
+ throw new Error(`Agents Relay service unavailable at ${host}:${port}: ${error instanceof Error ? error.message : String(error)}`);
160
+ }
161
+ if (!response.ok)
162
+ throw new Error(`Agents Relay job state command failed: ${await response.text() || `HTTP ${response.status}`}`);
163
+ return await response.json();
164
+ }
110
165
  export async function relayTaskCommand(args, body) {
111
166
  const host = arg(args, '--host', '127.0.0.1');
112
167
  const port = Number(arg(args, '--port', String(SERVICE_DEFAULTS.port)));