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
package/README.md CHANGED
@@ -1,8 +1,9 @@
1
1
  # Agents Relay
2
2
 
3
- Agents Relay is a generic TypeScript runtime for durable asynchronous agent jobs coordinated through GitHub pull requests. The PR body stores the durable Job marker plus human-readable Job objective, while PR comments store durable Task and other append-friendly records; correlated terminal events are authoritative for model-backed task lifecycle transitions.
3
+ Agents Relay is a TypeScript runtime for durable asynchronous agent Jobs driven locally by SQLite and canonical workflow Graphs. The EventBus wakes reconciliation; an EventBus message or GitHub PR comment is never the lifecycle authority. Job, Plan, Task, Review, Execution, Lease and Recovery state survive daemon restarts in SQLite.
4
+
5
+ V2 uses a read-only local SQLite Job Registry for discovery. The immutable jobId selects the Job Store even when prNumber is zero; scheduled slot claims are atomic across processes. GitHub is **audit-only**: its PR/head/publish events are persisted for reporting, never consulted to start, stop, or complete local Workflows. Local Git delivery and its confirmed merge receipt—not a remote PR MERGED fact—gate Graph completion. Job creation and local Task operations require no GitHub authentication or PR.
4
6
 
5
- V2 separates **operational execution state** from that collaboration record. A local RelayStore may use SQLite/WAL for runtime snapshots, event idempotency and a durable GitHub projection outbox. ProjectedDurableStore bootstraps from the existing GitHub representation, commits Job/Plan/Task runtime changes locally, and lets GitHubProjectionWriter project them asynchronously. GitHub therefore remains the human-readable durable Job/Plan/Task/Review surface without being required for every high-frequency runtime transition.
6
7
 
7
8
  The canonical V2 architecture contract is
8
9
  [DESIGN-PRINCIPLES.md](DESIGN-PRINCIPLES.md). README sections below describe
@@ -10,23 +11,27 @@ the runtime; they do not replace that contract.
10
11
 
11
12
  ## V2 architecture
12
13
 
13
- ```mermaid
14
+ \`\`\`mermaid
14
15
  flowchart LR
15
- Human[Human / orchestrator] --> GH[GitHub PR + comments<br/>collaboration + audit surface]
16
- GH --> Inbound[Typed inbound GitHub facts]
17
- Worker[Workers / adapters] --> Events[Event Bus<br/>lifecycle + progress facts]
18
- Inbound --> Coord[ReconcileCoordinator]
19
- Events --> Coord
20
- Coord --> Graphs[Executable Mermaid graphs<br/>workflow authority]
21
- Graphs --> Runtime[TypeScript primitives<br/>facts + guards + effects]
22
- Runtime --> Store[(SQLite/WAL RelayStore<br/>operational execution truth)]
23
- Store --> Outbox[Projection outbox]
24
- Outbox --> Writer[GitHubProjectionWriter]
25
- Writer --> GH
26
- Store --> Views[Dashboard / scheduler / recovery]
27
- ```
28
-
29
- RelayStore owns local operational execution facts; GitHub remains the human-readable collaboration and audit projection. Worker events are authoritative lifecycle inputs for model-backed tasks, but they are not a separate durable database. The graph engine owns workflow branching; TypeScript owns facts, persistence, locking/claims, I/O, and primitive guard/action implementations.
16
+ Human[Human / orchestrator] --> CLI[CLI / Dashboard]
17
+ CLI --> Store[(SQLite / WAL<br/>Job Task Plan Review truth)]
18
+ Worker[Workers / adapters] --> Events[EventBus<br/>wake + observations]
19
+ Events --> Coordinator[ReconcileCoordinator]
20
+ Store --> Coordinator
21
+ Coordinator --> Graphs[Canonical Mermaid Graph Engine]
22
+ Graphs --> Effects[Stateless actions / effects]
23
+ Effects --> Store
24
+ Store --> Views[Dashboard / Scheduler / Recovery]
25
+ Effects --> Outbox[Durable external-action outbox]
26
+ Outbox --> GH[GitHub Commit / PR / Review / Merge API]
27
+ GH --> Facts[Confirmed external facts]
28
+ Facts --> Store
29
+ \`\`\`
30
+
31
+ SQLite holds the sole lifecycle authority; the Graph Engine selects every transition.
32
+ GitHub operations are explicitly requested and confirmed by adapters, never used to
33
+ discover or reconstruct Job/Task state. EventBus delivery wakes reconciliation;
34
+ it cannot replace the durable SQLite record.
30
35
 
31
36
  ### Workflow graph navigation
32
37
 
@@ -53,7 +58,7 @@ These paths always represent the current canonical graphs. Historical graph defi
53
58
 
54
59
  ## Quick start
55
60
 
56
- For GitHub-backed work, create or adopt the PR through Agents Relay itself. This guarantees the durable job marker exists before any managed task is submitted.
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.
57
62
 
58
63
  ~~~sh
59
64
  cat > job.md <<'EOF'
@@ -183,7 +188,7 @@ Pushes to `main` run `.github/workflows/publish.yml`: install, test, choose the
183
188
 
184
189
  Normal consumers should invoke the CLI with `npx agents-relay ...` (or `npx agents-relayd ...`).
185
190
 
186
- 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.
187
192
 
188
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.
189
194
 
@@ -300,3 +305,34 @@ For autonomous Jobs, `UNBLOCKED` is historical: Relay does not retry that old Ta
300
305
  ### Browser Workspace product boundary
301
306
 
302
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,45 +1,27 @@
1
- import { GitHubStore } from './store.js';
2
- import { githubAuthContext } from './github-auth.js';
3
- import { registerBlockerDependency } from './blockers.js';
4
- import { arg, publishDependentUnblocked, repository, withEventBus } from './cli-support.js';
1
+ import { arg, relayControlCommand, relayControlJob, repository } from './cli-support.js';
5
2
  import { usage } from './cli-help.js';
6
3
  export async function runBlockerCommand(action, args) {
7
4
  if (!['add', 'resolve', 'show'].includes(action))
8
5
  throw new Error(usage);
9
- const repo = repository(args);
6
+ repository(args);
10
7
  const id = arg(args, '--id');
11
- const pr = Number(arg(args, '--pr'));
12
- if (!id || !Number.isInteger(pr) || pr <= 0)
13
- throw new Error('blocker commands require --repo, --pr, and --id for the blocker Job');
14
- const auth = await githubAuthContext(args);
15
- const blockerStore = new GitHubStore(auth.client, repo, pr, auth.trustedAuthors);
8
+ if (!id)
9
+ throw new Error('blocker commands require --repo and --id for the blocker Job');
16
10
  if (action === 'show') {
17
- console.log(JSON.stringify(await blockerStore.load(id), null, 2));
11
+ console.log(JSON.stringify(await relayControlJob(args, id), null, 2));
18
12
  return;
19
13
  }
20
14
  if (action === 'resolve') {
21
- const summary = await blockerStore.resolveBlockerDependents(id, 'verified');
22
- await withEventBus(args, bus => publishDependentUnblocked(bus, summary.unblocked, `Blocker Job ${id} resolved (verified)`));
23
- 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));
24
17
  return;
25
18
  }
26
- const dependentRepo = arg(args, '--dependent-repo');
27
- const dependentPr = Number(arg(args, '--dependent-pr'));
28
- const dependentJob = arg(args, '--dependent-job');
29
- const dependentTask = arg(args, '--dependent-task');
30
- if (!dependentRepo || !Number.isInteger(dependentPr) || dependentPr <= 0 || !dependentJob || !dependentTask) {
31
- throw new Error('blocker add requires --dependent-repo, --dependent-pr, --dependent-job, and --dependent-task');
32
- }
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');
33
23
  const modeValue = arg(args, '--resolution', 'merge');
34
24
  if (!['merge', 'verified'].includes(modeValue))
35
25
  throw new Error('--resolution must be merge or verified');
36
- const relation = await registerBlockerDependency({
37
- blockerStore,
38
- blockerJobId: id,
39
- dependentStore: new GitHubStore(auth.client, dependentRepo, dependentPr, auth.trustedAuthors),
40
- dependentJobId: dependentJob,
41
- dependentTaskId: dependentTask,
42
- resolutionMode: modeValue,
43
- });
44
- 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));
45
27
  }
package/dist/cli-help.js CHANGED
@@ -1,12 +1,14 @@
1
1
  export const usage = 'agents-relay <job|task|review|plan|blocker|projection|escalation|context|init|record|agent-register|agent-discover|status|reconcile|serve> [options]';
2
2
  const help = {
3
- root: `Agents Relay coordinates durable asynchronous agent jobs through GitHub pull requests.
3
+ root: `Agents Relay coordinates durable asynchronous agent jobs through local SQLite and workflow graphs.
4
4
 
5
5
  Usage:
6
- npx agents-relay <command> [options]
6
+ npx agents-relay <command> [options] [--no-github]
7
+
8
+ --no-github: SQLite/Graph-only mode. GitHub auth/API, PR creation, merge and fetch are disabled. Local completion is Graph-owned.
7
9
 
8
10
  Commands:
9
- job create|update|show|list|merge|unblock|cancel Manage durable GitHub-backed jobs
11
+ job create|update|show|list|merge|unblock|cancel Manage SQLite-authoritative jobs with optional GitHub PRs
10
12
  task create|update|show|list|complete|retry|unblock|cancel Manage durable tasks
11
13
  review request Request graph-owned review (optional thread override)
12
14
  plan Invoke the Planner for an existing managed Job
@@ -24,7 +26,7 @@ Commands:
24
26
 
25
27
  Run 'npx agents-relay <command> --help' for command options and examples.
26
28
  Use 'node dist/cli.js' only for explicit local development/demo mode.`,
27
- job: `Manage GitHub-backed durable jobs.
29
+ job: `Manage SQLite-authoritative durable jobs.
28
30
 
29
31
  Usage:
30
32
  npx agents-relay job <create|update|show|list|merge|unblock|cancel> [options]
@@ -34,7 +36,7 @@ Actions:
34
36
  update Partially update mutable metadata on a healthy existing Job
35
37
  show Show one durable Job
36
38
  list List managed Jobs in a repository
37
- merge Merge the backing PR and complete the Job
39
+ merge Queue a retryable external PR merge; Graph completes the Job only after confirmation
38
40
  unblock Authoritatively invalidate current BLOCKED decisions and trigger replanning/resume
39
41
  cancel Close the backing PR and cancel the Job
40
42
 
@@ -54,20 +56,20 @@ When provided, --thread-id persists a reviewer destination; otherwise the Job re
54
56
 
55
57
  Required: --repo OWNER/REPO --pr NUMBER --id JOB_ID.
56
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.`,
57
- 'job create': `Create a durable Job and its trusted marker.
59
+ 'job create': `Create a SQLite-authoritative Job.
58
60
 
59
61
  Required: --repo OWNER/REPO, --id JOB_ID, and either --head BRANCH or --pr NUMBER.
60
- With --head, reuse/create the PR for that head. With --pr, bind an existing unmanaged PR.
61
- 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.
62
- 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.
63
65
 
64
66
  Example:
65
- 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`,
66
68
  'job update': `Partially update mutable metadata on a healthy existing Job.
67
69
 
68
70
  Required: --repo OWNER/REPO, --pr NUMBER, and --id JOB_ID.
69
- Mutable: --title, --objective-file PATH|- (preferred) or --objective MARKDOWN, --project-path RELATIVE_PATH, --priority, --mode fixed|autonomous, --schedule, --tag JSON, --auto-merge|--no-auto-merge, --review|--no-review, --reviewer-thread-id ID, --creator-thread-id ID, and continuation options. Updating the objective also updates the PR body.
70
- Unspecified fields are preserved. Marker/binding corruption is never repaired implicitly; update fails with a precise diagnostic instead.
71
+ Mutable: --title, --objective-file PATH|- (preferred) or --objective MARKDOWN, --project-path RELATIVE_PATH, --priority, --mode fixed|autonomous, --schedule, --tag JSON, --auto-merge|--no-auto-merge, --review|--no-review, --reviewer-thread-id ID, --creator-thread-id ID, and continuation options. The objective updates local SQLite; PR body changes are independent external actions.
72
+ Unspecified fields are preserved. Local Job/PR identity mismatch is rejected.
71
73
 
72
74
  Example:
73
75
  npx agents-relay job update --repo OWNER/REPO --pr 12 --id job-1 --priority P0 --no-auto-merge`,
@@ -83,7 +85,7 @@ Required: --repo OWNER/REPO.
83
85
 
84
86
  Example:
85
87
  npx agents-relay job list --repo OWNER/REPO`,
86
- 'job merge': `Merge the backing pull request and complete the durable Job.
88
+ 'job merge': `Request an idempotent external pull-request merge; return QUEUED until confirmed, when the Graph Engine completes the Job.
87
89
 
88
90
  Required: --repo OWNER/REPO, --pr NUMBER, and --id JOB_ID. The current PR head SHA is pinned into the merge request.`,
89
91
  'job unblock': `Authoritatively unblock the effective BLOCKED work Tasks in one Job.\n\nRequired: --repo OWNER/REPO --pr NUMBER --id JOB_ID --reason TEXT. Autonomous Jobs transition blocked work to historical UNBLOCKED state and wake Planner with unblock facts as Plan trigger/context; fixed Jobs record UNBLOCKED first and then resume according to fixed-job retry semantics.`,
@@ -142,6 +144,9 @@ Required: --task-id ID, --summary TEXT, and the normal Job storage options. The
142
144
  'task retry': `Retry one durable Task.
143
145
 
144
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.`,
145
150
  'task cancel': `Cancel one durable Task.
146
151
 
147
152
  Required: --task-id ID and the normal Job storage options.`,
@@ -214,7 +219,7 @@ Example:
214
219
  npx agents-relay serve --repo OWNER/REPO --pr 12 --id job-1`,
215
220
  'agent-register': `Register an agent role and its execution endpoint.
216
221
 
217
- 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.
218
223
  Common: --capabilities a,b, --runtime shell|codex|chatgpt, --provider NAME, --model NAME.
219
224
 
220
225
  Example:
@@ -222,8 +227,8 @@ Example:
222
227
  projection: `Inspect and explicitly repair deterministic V2 GitHub projection conflicts.
223
228
 
224
229
  Usage:
225
- npx agents-relay projection conflicts --repo OWNER/REPO --pr NUMBER [--store PATH]
226
- 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
227
232
 
228
233
  A deterministic same-id/different-definition append conflict is classified once, stops
229
234
  consuming retry budget, and retains structured diagnostic evidence. It is never
@@ -232,7 +237,7 @@ auto-overwritten: an operator resolves it after reconciling the divergent defini
232
237
  discard-local Drop the blocked local mutation so a fresh one can be enqueued`,
233
238
  'agent-discover': `Find registered agents using hard filters.
234
239
 
235
- 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.
236
241
  Common: --capabilities a,b, --runtime NAME, --availability available,busy, --task-kind NAME, --explore.
237
242
 
238
243
  Example:
@@ -0,0 +1,77 @@
1
+ import { openLocalManagedStore } from './local-managed-store.js';
2
+ import { createJobEntity } from './workflow/entity-creation.js';
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';
7
+ /**
8
+ * Job lifecycle bootstrap: SQLite is authoritative. No GitHub request is
9
+ * required; an explicit PR number is optional external metadata.
10
+ */
11
+ export async function ensureManagedGitHubJob(_client, repositoryName, _trustedAuthors, options) {
12
+ if (!options.id)
13
+ throw new Error('--id is required for managed local Jobs');
14
+ const requestedNumber = options.prNumber ?? 0;
15
+ if (!Number.isInteger(requestedNumber) || requestedNumber < 0)
16
+ throw new Error('Invalid external PR binding number');
17
+ const placeholder = { number: requestedNumber, title: options.title ?? options.id,
18
+ body: options.body ?? options.objective ?? '', state: 'OPEN', head: options.head ?? '', base: options.base ?? '' };
19
+ const directory = options.storeDirectory ?? join(homedir(), '.agents-relay', 'stores');
20
+ const alreadyKnown = (await discoverLocalRelayStoreSnapshots(directory)).flatMap(snapshot => snapshot.jobs.filter(job => job.id === options.id).map(job => ({ job, path: snapshot.path })));
21
+ if (alreadyKnown.length > 1)
22
+ throw new Error('Duplicate immutable Job ID across SQLite stores: ' + options.id);
23
+ if (alreadyKnown.length === 1) {
24
+ const existing = alreadyKnown[0].job;
25
+ if (existing.repository !== repositoryName || (options.prNumber !== undefined && existing.prNumber !== requestedNumber))
26
+ throw new Error('Immutable Job ID is already bound to a different repository or external PR');
27
+ return { job: existing, pr: { ...placeholder, number: existing.prNumber } };
28
+ }
29
+ const pr = placeholder;
30
+ const number = pr.number;
31
+ const local = openLocalManagedStore(repositoryName, number, {
32
+ directory, jobId: options.id, noGitHub: true,
33
+ });
34
+ try {
35
+ const existing = await local.relay.loadJob(options.id);
36
+ if (existing)
37
+ return { job: existing, pr };
38
+ const now = new Date().toISOString();
39
+ const localMergeSource = options.localMergeSource ?? (options.head ? `refs/heads/${options.head}` : undefined);
40
+ if (options.localMergeTarget && options.localMergeTarget !== 'refs/heads/main')
41
+ throw new Error('Local merge target must always be main');
42
+ if (options.base && options.base !== 'main')
43
+ throw new Error('Job base must always be main');
44
+ const localMergeTarget = localMergeSource ? 'refs/heads/main' : undefined;
45
+ const job = createJobEntity({
46
+ id: options.id,
47
+ title: options.title || pr.title || 'Agents Relay job',
48
+ objective: options.objective || pr.body || undefined,
49
+ projectPath: options.projectPath,
50
+ description: pr.body || undefined,
51
+ priority: options.priority ?? 'P2',
52
+ executionMode: options.executionMode ?? 'fixed',
53
+ tag: options.tag,
54
+ autoMerge: true,
55
+ ...(localMergeSource && localMergeTarget ? { localMerge: { sourceRef: localMergeSource, targetRef: localMergeTarget, status: 'pending' } } : {}),
56
+ reviewEnabled: options.reviewEnabled ?? true,
57
+ ...(options.reviewerThreadId
58
+ ? { reviewerThreadId: normalizeThreadId(options.reviewerThreadId, 'reviewer thread ID') }
59
+ : {}),
60
+ ...(options.creatorThreadId
61
+ ? { creatorThreadId: normalizeThreadId(options.creatorThreadId, 'creator thread ID') }
62
+ : {}),
63
+ prNumber: pr.number,
64
+ repository: repositoryName,
65
+ continuation: options.continuation ?? null,
66
+ createdAt: now,
67
+ updatedAt: now,
68
+ tasks: [],
69
+ ...(options.schedule ? { schedule: options.schedule } : {}),
70
+ }, { activate: Boolean(options.schedule) });
71
+ await local.store.saveJob(job);
72
+ return { job: await local.store.load(options.id), pr };
73
+ }
74
+ finally {
75
+ local.relay.close();
76
+ }
77
+ }
@@ -0,0 +1,11 @@
1
+ export function configureLocalGitMerge(job, source, target, localOnly) {
2
+ // The GitHub integration is observational; local merge applies in either mode.
3
+ void localOnly;
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');
8
+ if (!['ACTIVE', 'DRAFT'].includes(job.state))
9
+ throw new Error('Cannot reconfigure merge of a terminal Job');
10
+ job.localMerge = { sourceRef: source, targetRef: target, status: 'pending' };
11
+ }
@@ -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
  }