agents-relay 2.0.66 → 2.0.68

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 (43) hide show
  1. package/README.md +24 -19
  2. package/dist/adapters.js +1 -1
  3. package/dist/cli-blocker.js +3 -5
  4. package/dist/cli-help.js +12 -10
  5. package/dist/cli-job-create.js +69 -0
  6. package/dist/cli-local-merge-config.js +9 -0
  7. package/dist/cli-support.js +25 -8
  8. package/dist/cli.js +66 -80
  9. package/dist/dashboard-html.js +1 -1
  10. package/dist/dashboard.js +1 -1
  11. package/dist/github-auth.js +4 -3
  12. package/dist/github-webhook.js +13 -5
  13. package/dist/local-authority-store.js +279 -0
  14. package/dist/local-git-merge.js +113 -0
  15. package/dist/local-job-path.js +7 -0
  16. package/dist/local-job-registry.js +23 -0
  17. package/dist/local-managed-store.js +21 -0
  18. package/dist/local-merge-delivery.js +52 -0
  19. package/dist/local-merge-lock.js +103 -0
  20. package/dist/no-github.js +11 -0
  21. package/dist/planned-review-head.js +7 -8
  22. package/dist/pool.js +72 -135
  23. package/dist/projected-store.js +20 -3
  24. package/dist/projection-outbox.js +3 -1
  25. package/dist/publication-store.js +37 -0
  26. package/dist/reconciler-review.js +25 -11
  27. package/dist/reconciler-runtime.js +15 -5
  28. package/dist/reconciler.js +33 -60
  29. package/dist/relay-store.js +108 -6
  30. package/dist/relayd.js +119 -124
  31. package/dist/runtime-status.js +8 -7
  32. package/dist/scheduled-jobs.js +43 -36
  33. package/dist/service-external-actions.js +27 -0
  34. package/dist/service-publication.js +29 -0
  35. package/dist/store-github.js +17 -6
  36. package/dist/task-update.js +1 -1
  37. package/dist/workflow/contracts.js +10 -1
  38. package/dist/workflow/definitions-v2.js +2 -0
  39. package/dist/workflow/job-reconcile-v2.js +9 -60
  40. package/dist/workflow/runtime.js +18 -3
  41. package/package.json +5 -3
  42. package/workflow/job-lifecycle.mmd +19 -60
  43. package/workflow/review-lifecycle.mmd +2 -8
@@ -0,0 +1,29 @@
1
+ import { homedir } from 'node:os';
2
+ import { join } from 'node:path';
3
+ import { eventFor } from './events.js';
4
+ import { SQLiteRelayStore, relayStorePath } from './relay-store.js';
5
+ /** Single-Job service uses the same durable publish facts as the repository daemon. */
6
+ export async function handleServicePublication(job, wake, bus, directory = join(homedir(), '.agents-relay', 'stores')) {
7
+ if (!wake.publication && !wake.publishFailure)
8
+ return;
9
+ const sqlite = new SQLiteRelayStore(relayStorePath(directory, job.repository, job.prNumber));
10
+ try {
11
+ await sqlite.recordPublication(job.id, wake.deliveryId, wake.publication, wake.publishFailure ?? null, wake.publicationObservedAt);
12
+ }
13
+ finally {
14
+ sqlite.close();
15
+ }
16
+ if (bus) {
17
+ if (wake.publication) {
18
+ const event = eventFor(job.id, 'release', null, 'package.published', 'succeeded', 'Published ' + wake.publication.package + '@' + wake.publication.version, 'orchestrator', { ...wake.publication, deliveryId: wake.deliveryId, repository: job.repository, prNumber: job.prNumber });
19
+ event.event_id = 'github:' + job.id + ':' + wake.deliveryId + ':package.published';
20
+ await bus.publish(event);
21
+ }
22
+ if (wake.publishFailure) {
23
+ const event = eventFor(job.id, 'release', null, 'package.publish_failed', 'failed', 'Publish failed: ' + wake.publishFailure.stage, 'orchestrator', { ...wake.publishFailure, deliveryId: wake.deliveryId, repository: job.repository, prNumber: job.prNumber });
24
+ event.event_id = 'github:' + job.id + ':' + wake.deliveryId + ':package.publish_failed';
25
+ await bus.publish(event);
26
+ }
27
+ }
28
+ // Do not use a publication event to forge a Job state change.
29
+ }
@@ -153,12 +153,23 @@ export class GitHubStore {
153
153
  const payload = { merge_method: 'squash' };
154
154
  if (expectedHeadSha)
155
155
  payload.sha = expectedHeadSha;
156
- const result = await this.client.request('repos/' + this.repository + '/pulls/' + this.prNumber + '/merge', {
157
- method: 'PUT',
158
- body: JSON.stringify(payload),
159
- });
160
- if (result.merged !== true)
161
- throw new Error(typeof result.message === 'string' ? result.message : 'Pull request #' + this.prNumber + ' was not merged');
156
+ try {
157
+ const result = await this.client.request('repos/' + this.repository + '/pulls/' + this.prNumber + '/merge', {
158
+ method: 'PUT',
159
+ body: JSON.stringify(payload),
160
+ });
161
+ if (result.merged !== true)
162
+ throw new Error(typeof result.message === 'string' ? result.message : 'Pull request #' + this.prNumber + ' was not merged');
163
+ }
164
+ catch (error) {
165
+ // Crash recovery can replay a merge after GitHub committed but before
166
+ // its local acknowledgement. Treat replay as success ONLY after verifying
167
+ // the actual external PR is already MERGED; never assume success offline.
168
+ this.prNodeCache = null;
169
+ const state = await this.pullRequestState().catch(() => null);
170
+ if (state !== 'MERGED')
171
+ throw error;
172
+ }
162
173
  this.prNodeCache = null;
163
174
  }
164
175
  async closePullRequest() {
@@ -44,7 +44,7 @@ export async function applyManagedTaskUpdate(store, jobId, taskId, proposed, exp
44
44
  }
45
45
  export function managedTaskUpdateHandler(locate, wake) {
46
46
  return async (jobId, taskId, task, expectedUpdatedAt) => {
47
- const target = locate(jobId);
47
+ const target = await locate(jobId);
48
48
  const updated = await applyManagedTaskUpdate(target.store, jobId, taskId, task, expectedUpdatedAt);
49
49
  await wake(target.repository, target.prNumber, jobId);
50
50
  return updated;
@@ -12,6 +12,9 @@ export const WORKFLOW_EVENT_NAMES = [
12
12
  'hold',
13
13
  'lease.expired',
14
14
  'implementation.completed',
15
+ 'local.completed',
16
+ 'local.merge.requested',
17
+ 'effect.deliverLocalMerge.failed',
15
18
  'pr.closed',
16
19
  'pr.merged',
17
20
  'queue',
@@ -48,7 +51,6 @@ export const WORKFLOW_FACT_NAMES = [
48
51
  'scheduledRun',
49
52
  'hasUnblock',
50
53
  'manualPlan',
51
- 'prOpen',
52
54
  'reviewDisabled',
53
55
  'implementationComplete',
54
56
  'headAvailable',
@@ -67,6 +69,7 @@ export const WORKFLOW_EFFECT_NAMES = [
67
69
  'effect.launchTask',
68
70
  'effect.cancelActiveTasks',
69
71
  'effect.mergePullRequest',
72
+ 'effect.deliverLocalMerge',
70
73
  'effect.cleanupJobWorktree',
71
74
  ];
72
75
  export const WORKFLOW_EFFECT_POLICIES = {
@@ -88,6 +91,12 @@ export const WORKFLOW_EFFECT_POLICIES = {
88
91
  retry: 'workflow',
89
92
  observability: 'workflow.effect',
90
93
  },
94
+ 'effect.deliverLocalMerge': {
95
+ idempotency: 'job-local-source-sha',
96
+ failureEvent: 'effect.deliverLocalMerge.failed',
97
+ retry: 'workflow',
98
+ observability: 'workflow.effect',
99
+ },
91
100
  'effect.mergePullRequest': {
92
101
  idempotency: 'job-head-sha',
93
102
  failureEvent: 'effect.mergePullRequest.failed',
@@ -35,6 +35,8 @@ export const jobLifecycleV2 = load('job-lifecycle', JOB_STATES, {
35
35
  implementationComplete: boolGuard('implementationComplete'),
36
36
  headAvailable: boolGuard('headAvailable'),
37
37
  mergeCapable: boolGuard('mergeCapable'),
38
+ localCompletionAllowed: boolGuard('localCompletionAllowed'),
39
+ localMergeReady: boolGuard('localMergeReady'),
38
40
  });
39
41
  function topologyTargets(graph, from) {
40
42
  const source = [...graph.nodes.values()].find(node => node.kind === 'state' && node.semanticId === from);
@@ -1,65 +1,14 @@
1
1
  import { decideJobLifecycle, dispatchJobLifecycle } from './lifecycle.js';
2
- import { implementationComplete } from '../runtime-status.js';
3
- import { applyHistoricalJobCompatibilityRepair } from './runtime.js';
4
- function lifecycleInput(facts) {
5
- if (facts.prState === 'MERGED')
6
- return { event: 'pr.merged', context: {} };
7
- if (facts.prState === 'CLOSED')
8
- return { event: 'pr.closed', context: { notMerged: true } };
9
- return {
10
- event: 'reconcile',
11
- context: {
12
- prOpen: true,
13
- headAvailable: Boolean(facts.headSha),
14
- mergeCapable: facts.mergeCapable,
15
- },
16
- };
17
- }
18
- function graphFacts(job, facts) {
19
- const input = lifecycleInput(facts);
20
- if (input.event !== 'reconcile')
21
- return input.context;
22
- return {
23
- ...input.context,
24
- reviewDisabled: job.reviewEnabled === false,
25
- implementationComplete: implementationComplete(job),
26
- autoMerge: job.autoMerge !== false,
27
- };
28
- }
29
- /**
30
- * Historical compatibility only. Normal Job lifecycle is always decided by
31
- * workflow/job-lifecycle.mmd from the PR fact-derived event.
32
- */
33
- function repairHistoricalTerminalState(job, facts) {
34
- if (['DRAFT', 'ACTIVE', 'COMPLETED', 'CANCELLED'].includes(job.state))
35
- return null;
36
- if (facts.prState === 'MERGED')
37
- applyHistoricalJobCompatibilityRepair(job, 'COMPLETED');
38
- else if (facts.prState === 'CLOSED')
39
- applyHistoricalJobCompatibilityRepair(job, 'CANCELLED');
40
- else
41
- return null;
42
- return job.state;
43
- }
2
+ const localFacts = (_job, facts) => ({
3
+ jobActive: facts.jobActive,
4
+ implementationComplete: facts.implementationComplete,
5
+ headAvailable: facts.headAvailable,
6
+ deliveryReady: facts.deliveryReady,
7
+ });
8
+ /** Local reconciliation cannot invent a completion event; local Git delivery owns it. */
44
9
  export function decideJobReconcile(job, facts) {
45
- const input = lifecycleInput(facts);
46
- return decideJobLifecycle(job, input.event, graphFacts(job, facts));
10
+ return decideJobLifecycle(job, 'reconcile', localFacts(job, facts));
47
11
  }
48
12
  export async function dispatchJobReconcile(job, facts, executeEffect, persist) {
49
- const repaired = repairHistoricalTerminalState(job, facts);
50
- if (repaired) {
51
- await persist?.();
52
- await executeEffect?.('effect.cancelActiveTasks');
53
- return {
54
- accepted: true,
55
- target: repaired,
56
- guard: null,
57
- actions: ['effect.cancelActiveTasks'],
58
- mutations: [],
59
- effects: ['effect.cancelActiveTasks'],
60
- reason: 'accepted',
61
- };
62
- }
63
- const input = lifecycleInput(facts);
64
- return dispatchJobLifecycle(job, input.event, graphFacts(job, facts), executeEffect, persist);
13
+ return dispatchJobLifecycle(job, 'reconcile', localFacts(job, facts), executeEffect, persist);
65
14
  }
@@ -35,15 +35,30 @@ export function applyTaskMutation(task, mutation) {
35
35
  if (mutation === 'mutation.markPlannerUnavailable')
36
36
  task.error = 'No objective planner configured';
37
37
  }
38
- export function applyTaskGraphTarget(task, target, timestamp = new Date().toISOString()) { task.state = target; task.updatedAt = timestamp; }
39
- export function applyJobGraphTarget(job, target, timestamp = new Date().toISOString()) { job.state = target; job.updatedAt = timestamp; }
38
+ export function applyTaskGraphTarget(task, target, timestamp = new Date().toISOString()) {
39
+ const previous = Date.parse(task.updatedAt), candidate = Date.parse(timestamp);
40
+ task.state = target;
41
+ task.updatedAt = Number.isFinite(previous)
42
+ ? new Date(Math.max(Number.isFinite(candidate) ? candidate : Date.now(), previous + 1)).toISOString()
43
+ : timestamp;
44
+ }
45
+ export function applyJobGraphTarget(job, target, timestamp = new Date().toISOString()) {
46
+ const previous = Date.parse(job.updatedAt), candidate = Date.parse(timestamp);
47
+ job.state = target;
48
+ job.updatedAt = Number.isFinite(previous)
49
+ ? new Date(Math.max(Number.isFinite(candidate) ? candidate : Date.now(), previous + 1)).toISOString()
50
+ : timestamp;
51
+ }
40
52
  export function applyTaskWorkflowDecision(task, decision, timestamp = new Date().toISOString()) {
41
53
  if (!decision.accepted || !decision.target)
42
54
  throw new Error(`Cannot apply rejected workflow decision to task ${task.id}`);
43
55
  task.state = decision.target;
44
56
  for (const mutation of decision.mutations)
45
57
  applyTaskMutation(task, mutation);
46
- task.updatedAt = timestamp;
58
+ const previous = Date.parse(task.updatedAt), candidate = Date.parse(timestamp);
59
+ task.updatedAt = Number.isFinite(previous)
60
+ ? new Date(Math.max(Number.isFinite(candidate) ? candidate : Date.now(), previous + 1)).toISOString()
61
+ : timestamp;
47
62
  }
48
63
  export function applyJobWorkflowDecision(job, decision, timestamp = new Date().toISOString()) {
49
64
  if (!decision.accepted || !decision.target)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-relay",
3
- "version": "2.0.66",
3
+ "version": "2.0.68",
4
4
  "description": "Durable async agent jobs coordinated through GitHub pull requests",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,10 +9,12 @@
9
9
  },
10
10
  "scripts": {
11
11
  "build": "tsc -p tsconfig.json && chmod +x dist/cli.js dist/relayd.js dist/relayd-main.js",
12
- "test": "npm run build && node --test test/*.test.js",
12
+ "test": "npm run test:full",
13
13
  "test:chatgpt-temp-quality": "AGENTS_RELAY_RUN_CHATGPT_TEMP_QUALITY=1 sh -c \"npm run build && node test/quality/run-temporary-chat.mjs\"",
14
14
  "dev": "tsx src/cli.ts",
15
- "test:publish": "npm run build && node --test --test-force-exit test/*.test.js"
15
+ "test:publish": "npm run build && node --test --test-force-exit test/*.test.js",
16
+ "test:fast": "npm run build && node --test --test-concurrency=2 test/workflow-lifecycle.test.js test/workflow-v2-lifecycle-parity.test.js test/workflow-effects.test.js test/relay-store.test.js test/local-authority.test.js test/local-jobid-routing.test.js test/local-sqlite-jobid.test.js test/no-github.test.js test/scheduled-jobs.test.js test/reconcile-coordinator.test.js test/service-publication.test.js test/github-webhook.test.js",
17
+ "test:full": "npm run build && node --test --test-concurrency=1 test/*.test.js"
16
18
  },
17
19
  "devDependencies": {
18
20
  "@types/node": "^22.10.2",
@@ -4,73 +4,32 @@ draft["state: DRAFT"]
4
4
  active["state: ACTIVE"]
5
5
  completed["state: COMPLETED"]
6
6
  cancelled["state: CANCELLED"]
7
- notMergedDraft["guard: notMerged"]
8
- notMergedActive["guard: notMerged"]
9
- autoMergeReview["guard: autoMerge"]
10
- autoMergeImplementation["guard: autoMerge"]
11
- prOpenReconcile["guard: prOpen"]
12
- reviewDisabledReconcile["guard: reviewDisabled"]
13
- implementationCompleteReconcile["guard: implementationComplete"]
14
- autoMergeReconcile["guard: autoMerge"]
15
- headAvailableReconcile["guard: headAvailable"]
16
- mergeCapableReconcile["guard: mergeCapable"]
17
- mergeReconcile["action: effect.mergePullRequest"]
18
- cancelDraftMerge["action: effect.cancelActiveTasks"]
19
- cancelDraftClose["action: effect.cancelActiveTasks"]
20
- mergeReview["action: effect.mergePullRequest"]
21
- mergeImplementation["action: effect.mergePullRequest"]
22
- cancelActiveMerge["action: effect.cancelActiveTasks"]
23
- cancelActiveClose["action: effect.cancelActiveTasks"]
24
- cancelCancelledMerge["action: effect.cancelActiveTasks"]
7
+
8
+ cancelDraft["action: effect.cancelActiveTasks"]
9
+ cancelActive["action: effect.cancelActiveTasks"]
10
+ localCompletionGuard["guard: localCompletionAllowed"]
11
+ localMergeReadyGuard["guard: localMergeReady"]
12
+ localMergeDelivery["action: effect.deliverLocalMerge"]
25
13
 
26
14
  draft -->|on: activate| active
27
- draft -->|on: pr.merged| cancelDraftMerge
28
- cancelDraftMerge --> completed
29
15
  draft -->|on: cancel| cancelDraft
30
- cancelDraft["action: effect.cancelActiveTasks"]
31
16
  cancelDraft --> cancelled
32
- draft -->|on: pr.closed| notMergedDraft
33
- notMergedDraft -->|true| cancelDraftClose
34
- cancelDraftClose --> cancelled
35
-
36
- active -->|on: reconcile| prOpenReconcile
37
- prOpenReconcile -->|true| reviewDisabledReconcile
38
- prOpenReconcile -->|false| active
39
- reviewDisabledReconcile -->|true| implementationCompleteReconcile
40
- reviewDisabledReconcile -->|false| active
41
- implementationCompleteReconcile -->|true| autoMergeReconcile
42
- implementationCompleteReconcile -->|false| active
43
- autoMergeReconcile -->|true| headAvailableReconcile
44
- autoMergeReconcile -->|false| active
45
- headAvailableReconcile -->|true| mergeCapableReconcile
46
- headAvailableReconcile -->|false| active
47
- mergeCapableReconcile -->|true| mergeReconcile
48
- mergeCapableReconcile -->|false| active
49
- mergeReconcile --> active
50
17
 
51
- active -->|on: review.approved| autoMergeReview
52
- autoMergeReview -->|true| mergeReview
53
- mergeReview --> active
54
- active -->|on: implementation.completed| autoMergeImplementation
55
- autoMergeImplementation -->|true| mergeImplementation
56
- mergeImplementation --> active
57
- active -->|on: effect.mergePullRequest.failed| active
58
- active -->|on: pr.merged| cancelActiveMerge
59
- cancelActiveMerge --> completed
18
+ active -->|on: reconcile| active
19
+ active -->|on: review.approved| active
20
+ active -->|on: implementation.completed| active
60
21
  active -->|on: cancel| cancelActive
61
- cancelActive["action: effect.cancelActiveTasks"]
62
22
  cancelActive --> cancelled
63
- active -->|on: pr.closed| notMergedActive
64
- notMergedActive -->|true| cancelActiveClose
65
- cancelActiveClose --> cancelled
66
23
 
67
- cancelled -->|on: pr.merged| cancelCancelledMerge
68
- cancelCancelledMerge --> completed
69
- cancelled -->|on: effect.cancelActiveTasks.failed| cancelled
70
- completed -->|on: effect.cancelActiveTasks.failed| completed
24
+ active -->|on: local.completed| localCompletionGuard
25
+ localCompletionGuard -->|true| completed
26
+ localCompletionGuard -->|false| active
71
27
 
72
- %% Cleanup eligibility belongs to the lifecycle graph and is retryable after restart.
73
- cleanupCompletedWorktree["action: effect.cleanupJobWorktree"]
74
- completed -->|on: pr.merged| cleanupCompletedWorktree
75
- cleanupCompletedWorktree --> completed
28
+ active -->|on: local.merge.requested| localMergeReadyGuard
29
+ localMergeReadyGuard -->|true| localMergeDelivery
30
+ localMergeDelivery --> active
31
+ localMergeReadyGuard -->|false| active
32
+ active -->|on: effect.deliverLocalMerge.failed| active
33
+
34
+ cancelled -->|on: effect.cancelActiveTasks.failed| cancelled
76
35
  completed -->|on: effect.cleanupJobWorktree.failed| completed
@@ -10,7 +10,6 @@ stale["state: STALE"]
10
10
 
11
11
  unscheduledNone["guard: unscheduled"]
12
12
  reviewEnabledNone["guard: reviewEnabled"]
13
- prOpenNone["guard: prOpen"]
14
13
  jobActiveNone["guard: jobActive"]
15
14
  implementationCompleteNone["guard: implementationComplete"]
16
15
  headAvailableNone["guard: headAvailable"]
@@ -19,7 +18,6 @@ noActiveReviewNone["guard: noActiveReviewTask"]
19
18
  noApprovedReviewNone["guard: noApprovedCurrentReview"]
20
19
  unscheduledStale["guard: unscheduled"]
21
20
  reviewEnabledStale["guard: reviewEnabled"]
22
- prOpenStale["guard: prOpen"]
23
21
  jobActiveStale["guard: jobActive"]
24
22
  implementationCompleteStale["guard: implementationComplete"]
25
23
  headAvailableStale["guard: headAvailable"]
@@ -47,10 +45,8 @@ mergeStarted["action: review.requestMerge"]
47
45
  none -->|on: reconcile| unscheduledNone
48
46
  unscheduledNone -->|true| reviewEnabledNone
49
47
  unscheduledNone -->|false| none
50
- reviewEnabledNone -->|true| prOpenNone
48
+ reviewEnabledNone -->|true| jobActiveNone
51
49
  reviewEnabledNone -->|false| none
52
- prOpenNone -->|true| jobActiveNone
53
- prOpenNone -->|false| none
54
50
  jobActiveNone -->|true| implementationCompleteNone
55
51
  jobActiveNone -->|false| none
56
52
  implementationCompleteNone -->|true| headAvailableNone
@@ -68,10 +64,8 @@ createNone --> pending
68
64
  stale -->|on: reconcile| unscheduledStale
69
65
  unscheduledStale -->|true| reviewEnabledStale
70
66
  unscheduledStale -->|false| stale
71
- reviewEnabledStale -->|true| prOpenStale
67
+ reviewEnabledStale -->|true| jobActiveStale
72
68
  reviewEnabledStale -->|false| stale
73
- prOpenStale -->|true| jobActiveStale
74
- prOpenStale -->|false| stale
75
69
  jobActiveStale -->|true| implementationCompleteStale
76
70
  jobActiveStale -->|false| stale
77
71
  implementationCompleteStale -->|true| headAvailableStale