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/dist/pool.js CHANGED
@@ -1,12 +1,13 @@
1
- import { GitHubStore, listManagedGitHubJobs, withGitHubRequestContext } from './store.js';
1
+ import { listManagedGitHubJobs } from './store.js';
2
2
  import { eventFor } from './events.js';
3
- import { runScheduledBucket } from './scheduled-jobs.js';
3
+ import { runScheduledBucketFromStore } from './scheduled-jobs.js';
4
4
  import { scheduleBucket } from './scheduler.js';
5
5
  import { ReconcileCoordinator } from './reconcile-coordinator.js';
6
6
  import { errorMessage, observability, withObservabilityCorrelation } from './observability.js';
7
+ import { jobHasActiveExecution } from './job-execution-slot.js';
7
8
  const RANK = { P0: 0, P1: 1, P2: 2, P3: 3 };
8
9
  export function compareJobPriority(a, b) { return RANK[a.priority ?? 'P2'] - RANK[b.priority ?? 'P2'] || a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id); }
9
- export function runnableManagedJobs(items) { return items.filter(item => item.githubState === 'OPEN' && item.job.state === 'ACTIVE').map(item => item.job).sort(compareJobPriority); }
10
+ export function runnableManagedJobs(items) { return items.filter(item => item.job.state === 'ACTIVE').map(item => item.job).sort(compareJobPriority); }
10
11
  export function needsLifecycleReconciliation(job, now = Date.now()) {
11
12
  // DRAFT create-only Jobs stay dormant until a caller explicitly creates work.
12
13
  if (job.tasks.length === 0)
@@ -15,38 +16,29 @@ export function needsLifecycleReconciliation(job, now = Date.now()) {
15
16
  return true;
16
17
  return job.tasks.some(task => ['RUNNING', 'QUEUED'].includes(task.state) && task.executionId !== null && task.leaseExpiresAt !== null && new Date(task.leaseExpiresAt).getTime() <= now);
17
18
  }
18
- export function terminalManagedJobs(items) { return items.filter(item => item.githubState !== 'OPEN' && ((item.githubState === 'MERGED' && item.job.state !== 'COMPLETED') || (item.githubState === 'CLOSED' && item.job.state !== 'CANCELLED'))).map(item => item.job).sort(compareJobPriority); }
19
+ export function terminalManagedJobs(_items) { return []; }
19
20
  export async function aggregateManagedGitHubJobs(client, repositories, trustedAuthors) {
20
21
  const uniqueRepositories = [...new Set(repositories)];
21
22
  const groups = await Promise.all(uniqueRepositories.map(repository => listManagedGitHubJobs(client, repository, trustedAuthors)));
22
23
  return groups.flat().sort((a, b) => b.job.updatedAt.localeCompare(a.job.updatedAt) || b.job.prNumber - a.job.prNumber);
23
24
  }
24
25
  export class RepositoryWorkerPool {
25
- client;
26
- trustedAuthors;
27
26
  concurrency;
28
27
  makeReconciler;
29
- isRateLimited;
30
- refreshWebhookSnapshot;
31
- makeStore;
28
+ localInventory;
29
+ resolveLocalJob;
32
30
  running = false;
33
31
  pending = false;
34
32
  reconcileLocks = new Map();
35
33
  coordinator;
36
- githubSignalCoordinator;
37
34
  scheduleSignalCoordinator;
35
+ dispatchTail = Promise.resolve();
38
36
  watchedBus;
39
- jobTargets = new Map();
40
- targetSeeds = new Map();
41
- constructor(client, repository, trustedAuthors, concurrency, makeReconciler, isRateLimited = () => false, refreshWebhookSnapshot, makeStore = (repository, prNumber, seed) => new GitHubStore(this.client, repository, prNumber, this.trustedAuthors, seed)) {
42
- this.client = client;
43
- this.trustedAuthors = trustedAuthors;
37
+ constructor(concurrency, makeReconciler, localInventory, resolveLocalJob) {
44
38
  this.concurrency = concurrency;
45
39
  this.makeReconciler = makeReconciler;
46
- this.isRateLimited = isRateLimited;
47
- this.refreshWebhookSnapshot = refreshWebhookSnapshot;
48
- this.makeStore = makeStore;
49
- this.repositories = [...new Set(typeof repository === 'string' ? [repository] : repository)];
40
+ this.localInventory = localInventory;
41
+ this.resolveLocalJob = resolveLocalJob;
50
42
  const reportCoordinatorError = (error, signal) => {
51
43
  observability().record({
52
44
  stage: 'reconcile', event: 'reconcile.coordinator_failed', level: 'error', outcome: 'failed',
@@ -56,10 +48,29 @@ export class RepositoryWorkerPool {
56
48
  });
57
49
  };
58
50
  this.coordinator = new ReconcileCoordinator((jobId, signals) => this.consumeSignals(jobId, signals), reportCoordinatorError);
59
- this.githubSignalCoordinator = new ReconcileCoordinator((_key, signals) => this.consumeGitHubSignals(signals), reportCoordinatorError);
60
51
  this.scheduleSignalCoordinator = new ReconcileCoordinator((jobId, signals) => this.consumeScheduleSignals(jobId, signals), reportCoordinatorError);
61
52
  }
62
- repositories;
53
+ async localTarget(jobId) {
54
+ return this.resolveLocalJob(jobId);
55
+ }
56
+ async requireLocalStore(jobId) {
57
+ const resolved = await this.localTarget(jobId);
58
+ if (!resolved)
59
+ throw new Error('Authoritative SQLite Job is not registered: ' + jobId);
60
+ return resolved.store;
61
+ }
62
+ async withDispatchGate(work) {
63
+ const previous = this.dispatchTail;
64
+ let release;
65
+ this.dispatchTail = new Promise(resolve => { release = resolve; });
66
+ await previous;
67
+ try {
68
+ await work();
69
+ }
70
+ finally {
71
+ release();
72
+ }
73
+ }
63
74
  async reconcileExclusive(jobId, work) {
64
75
  while (this.reconcileLocks.has(jobId))
65
76
  await this.reconcileLocks.get(jobId);
@@ -84,107 +95,57 @@ export class RepositoryWorkerPool {
84
95
  return `event.${event.type}`;
85
96
  }
86
97
  async consumeScheduleSignals(jobId, signals) {
87
- if (this.isRateLimited() || signals.length === 0)
98
+ const event = [...signals].reverse().find(item => item.event?.type === 'schedule.tick')?.event;
99
+ const bucket = typeof event?.data?.bucket === 'string' ? event.data.bucket : null;
100
+ const tickAt = new Date(typeof event?.data?.tickAt === 'string' ? event.data.tickAt : event?.timestamp ?? Date.now());
101
+ if (!bucket || Number.isNaN(tickAt.getTime()))
88
102
  return;
89
- const signal = [...signals].reverse().find(item => item.event?.type === 'schedule.tick');
90
- const event = signal?.event;
91
- const repository = signal?.repository ?? (typeof event?.data?.repository === 'string' ? event.data.repository : undefined);
92
- const prValue = signal?.prNumber ?? Number(event?.data?.prNumber);
93
- const prNumber = Number.isInteger(prValue) && Number(prValue) > 0 ? Number(prValue) : undefined;
94
- const bucket = typeof event?.data?.bucket === 'string' ? event.data.bucket : undefined;
95
- const tickAt = typeof event?.data?.tickAt === 'string' ? new Date(event.data.tickAt) : new Date(event?.timestamp ?? Date.now());
96
- if (!repository || !prNumber || !bucket || !this.repositories.includes(repository) || Number.isNaN(tickAt.getTime()))
103
+ const target = await this.localTarget(jobId);
104
+ if (!target)
97
105
  return;
98
- this.jobTargets.set(jobId, { repository, prNumber });
99
- await runScheduledBucket(this.client, repository, this.trustedAuthors, { prNumber, jobId, bucket }, { reconcile: async () => { await this.reconcileTarget(repository, prNumber, jobId, 'schedule.tick'); }, idle: async () => { } }, tickAt, () => this.makeStore(repository, prNumber));
106
+ await runScheduledBucketFromStore(target.store, { jobId, bucket }, {
107
+ reconcile: async () => this.reconcileLocalJob(jobId, 'schedule.tick'),
108
+ idle: async () => { },
109
+ }, tickAt);
100
110
  }
101
- async consumeGitHubSignals(_signals) {
102
- // GitHub webhooks are fact-only inputs. They must never trigger a GitHub reread.
111
+ async reconcileLocalJob(jobId, reason = 'local.request') {
112
+ const target = await this.localTarget(jobId);
113
+ if (!target)
114
+ throw new Error('Local job not found: ' + jobId);
115
+ await this.withDispatchGate(async () => {
116
+ await this.reconcileExclusive(jobId, async () => {
117
+ await withObservabilityCorrelation({ jobId, repository: target.job.repository }, () => this.makeReconciler(target.store, 0).reconcile(jobId));
118
+ });
119
+ await this.reconcileDiscovered(await this.localInventory());
120
+ });
121
+ void reason;
103
122
  }
104
123
  async consumeSignals(jobId, signals) {
105
- for (const signal of signals) {
106
- if (signal.repository && signal.prNumber && this.repositories.includes(signal.repository)) {
107
- this.jobTargets.set(jobId, { repository: signal.repository, prNumber: signal.prNumber });
108
- }
109
- }
110
- const target = this.jobTargets.get(jobId);
111
- if (!target || !this.repositories.includes(target.repository)) {
112
- observability().record({
113
- stage: 'reconcile', event: 'reconcile.signal_unrouted', level: 'warn', outcome: 'skipped',
114
- message: `Reconcile signal target unknown for job ${jobId}`,
115
- correlation: { jobId },
116
- data: { reasons: signals.map(signal => signal.reason) },
117
- });
124
+ const target = await this.localTarget(jobId);
125
+ if (!target)
118
126
  return;
119
- }
120
- const seedKey = `${target.repository}#${target.prNumber}:${jobId}`;
121
- const mayConsumeSeed = signals.some(signal => signal.reason === 'target.request' || signal.reason.startsWith('github.webhook:'));
122
- let seed = mayConsumeSeed ? this.targetSeeds.get(seedKey) ?? null : null;
123
- if (mayConsumeSeed)
124
- this.targetSeeds.delete(seedKey);
125
- const needsFreshPullRequestFacts = signals.some(signal => !signal.event || signal.event.type === 'task.completed');
126
- if (!seed && needsFreshPullRequestFacts && this.refreshWebhookSnapshot && !this.isRateLimited()) {
127
- const trigger = `coordinator.${signals.map(signal => signal.reason).join('+') || 'reconcile'}`;
128
- seed = (await this.refreshWebhookSnapshot(target.repository, target.prNumber, trigger)).seed;
129
- }
130
- const store = this.makeStore(target.repository, target.prNumber, seed);
131
- const reconciler = this.makeReconciler(store, Math.max(1, this.concurrency));
127
+ const reconciler = this.makeReconciler(target.store, Math.max(1, this.concurrency));
132
128
  let shouldReconcile = false;
133
- const reasons = [];
134
129
  for (const signal of signals) {
135
- reasons.push(signal.reason);
136
130
  if (!signal.event) {
137
131
  shouldReconcile = true;
138
132
  continue;
139
133
  }
140
- const result = await withGitHubRequestContext(this.client, `pool.coordinator-apply:${signal.reason}`, () => reconciler.applyEvent(signal.event));
141
- if (!result.applied)
142
- observability().record({
143
- stage: 'transition', event: 'lifecycle.event_rejected', level: 'warn', outcome: 'skipped',
144
- message: `Lifecycle event ${signal.event.type} not applied: ${result.reason}`,
145
- correlation: { repository: target.repository, prNumber: target.prNumber, jobId, taskId: signal.event.task_id },
146
- data: { eventId: signal.event.event_id, sourceId: signal.event.source?.id, reason: result.reason, eventTimestamp: signal.event.timestamp },
147
- });
148
- shouldReconcile ||= result.reconcile;
149
- }
150
- if (!shouldReconcile) {
151
- observability().record({
152
- stage: 'wake', event: 'wake.signal', level: 'debug', outcome: 'noop',
153
- message: `Wake for ${target.repository}#${target.prNumber} produced no reconcile work`,
154
- correlation: { repository: target.repository, prNumber: target.prNumber, jobId },
155
- data: { reasons: [...new Set(reasons)] },
156
- });
157
- return;
134
+ if (signal.event.type === 'github.webhook')
135
+ continue;
136
+ const outcome = await reconciler.applyEvent(signal.event);
137
+ shouldReconcile ||= outcome.reconcile;
158
138
  }
159
- const reason = [...new Set(reasons)].join('+') || 'unknown';
160
- observability().record({
161
- stage: 'wake', event: 'wake.signal', outcome: 'started',
162
- message: `Wake for ${target.repository}#${target.prNumber}: ${reason}`,
163
- correlation: { repository: target.repository, prNumber: target.prNumber, jobId },
164
- data: { reasons: [...new Set(reasons)] },
165
- });
166
- await this.reconcileExclusive(jobId, async () => {
167
- await withObservabilityCorrelation({ repository: target.repository, prNumber: target.prNumber, jobId }, () => withGitHubRequestContext(this.client, `pool.coordinator-reconcile:${reason}`, () => reconciler.reconcile(jobId)));
168
- });
139
+ if (shouldReconcile)
140
+ await this.reconcileLocalJob(jobId, 'local.signal');
169
141
  }
170
142
  async reconcileDiscovered(discovered) {
171
- for (const item of discovered)
172
- this.jobTargets.set(item.job.id, { repository: item.job.repository, prNumber: item.job.prNumber });
173
- // Terminal PRs must be reconciled before OPEN-job scheduling. Otherwise the
174
- // OPEN-only runnable filter can strand durable jobs after GitHub merges/closes them.
175
- for (const job of terminalManagedJobs(discovered)) {
176
- await this.reconcileExclusive(job.id, async () => {
177
- const store = this.makeStore(job.repository, job.prNumber);
178
- await store.hydrate?.(job);
179
- await withGitHubRequestContext(this.client, 'pool.terminal-reconcile', () => this.makeReconciler(store, 0).reconcile(job.id));
180
- });
181
- }
182
143
  const jobs = runnableManagedJobs(discovered);
183
- let capacity = Math.max(1, this.concurrency);
144
+ let capacity = Math.max(0, this.concurrency - jobs.filter(job => jobHasActiveExecution(job)).length);
184
145
  for (const job of jobs) {
185
146
  if (capacity <= 0)
186
147
  break;
187
- const store = this.makeStore(job.repository, job.prNumber);
148
+ const store = await this.requireLocalStore(job.id);
188
149
  await store.hydrate?.(job);
189
150
  const ready = job.tasks.filter(task => (task.state === 'QUEUED' && task.executionId === null) || (task.state === 'DRAFT' && task.dependencies.length > 0)).length;
190
151
  const expiredRunning = job.tasks.some(task => ['RUNNING', 'QUEUED'].includes(task.state) && task.executionId !== null && task.leaseExpiresAt !== null && new Date(task.leaseExpiresAt).getTime() <= Date.now());
@@ -192,7 +153,7 @@ export class RepositoryWorkerPool {
192
153
  continue;
193
154
  const allowance = Math.max(1, Math.min(capacity, ready || (expiredRunning ? 1 : 0) || 1));
194
155
  await this.reconcileExclusive(job.id, async () => {
195
- await withGitHubRequestContext(this.client, 'pool.discovered-reconcile', () => this.makeReconciler(store, allowance).reconcile(job.id));
156
+ await this.makeReconciler(store, allowance).reconcile(job.id);
196
157
  });
197
158
  capacity -= allowance;
198
159
  }
@@ -200,26 +161,23 @@ export class RepositoryWorkerPool {
200
161
  async reconcileKnown(discovered) {
201
162
  const recovered = [];
202
163
  for (const item of discovered) {
203
- if (item.githubState !== 'OPEN' || item.job.state !== 'ACTIVE') {
164
+ if (item.job.state !== 'ACTIVE') {
204
165
  recovered.push(item);
205
166
  continue;
206
167
  }
207
- this.jobTargets.set(item.job.id, { repository: item.job.repository, prNumber: item.job.prNumber });
208
- const store = this.makeStore(item.job.repository, item.job.prNumber);
168
+ const store = await this.requireLocalStore(item.job.id);
209
169
  await store.hydrate?.(item.job);
210
170
  const reconciler = this.makeReconciler(store, 0);
211
171
  let recoveredJob = item.job;
212
172
  if (typeof reconciler.recoverStartupOrphans === 'function')
213
173
  await this.reconcileExclusive(item.job.id, async () => {
214
- recoveredJob = await withGitHubRequestContext(this.client, 'pool.startup-orphan-recovery', () => reconciler.recoverStartupOrphans(item.job.id));
174
+ recoveredJob = await reconciler.recoverStartupOrphans(item.job.id);
215
175
  });
216
176
  recovered.push({ ...item, job: recoveredJob });
217
177
  }
218
178
  await this.reconcileDiscovered(recovered);
219
179
  }
220
180
  async reconcile() {
221
- if (this.isRateLimited())
222
- return;
223
181
  if (this.running) {
224
182
  this.pending = true;
225
183
  return;
@@ -228,34 +186,26 @@ export class RepositoryWorkerPool {
228
186
  try {
229
187
  do {
230
188
  this.pending = false;
231
- await this.reconcileDiscovered(await withGitHubRequestContext(this.client, 'pool.workspace-scan', () => aggregateManagedGitHubJobs(this.client, this.repositories, this.trustedAuthors)));
189
+ await this.withDispatchGate(async () => this.reconcileDiscovered(await this.localInventory()));
232
190
  } while (this.pending);
233
191
  }
234
192
  finally {
235
193
  this.running = false;
236
194
  }
237
195
  }
238
- seedTargetSnapshot(repository, prNumber, jobId, seed) {
239
- if (!this.repositories.includes(repository))
240
- return;
241
- this.targetSeeds.set(`${repository}#${prNumber}:${jobId}`, seed);
242
- }
243
- async reconcileTarget(repository, prNumber, jobId, reason = 'target.request') {
244
- if (this.isRateLimited() || !this.repositories.includes(repository))
196
+ async reconcileTarget(_repository, _prNumber, jobId, reason = 'target.request') {
197
+ // Transport metadata is never used for routing; reject observational wakeups.
198
+ if (reason.startsWith('github.webhook:'))
245
199
  return;
246
- this.jobTargets.set(jobId, { repository, prNumber });
247
- await this.coordinator.accept({ jobId, reason, repository, prNumber });
200
+ await this.coordinator.accept({ jobId, reason });
248
201
  }
249
202
  async watch(bus) {
250
203
  this.watchedBus = bus;
251
204
  const unsubscribe = await bus.subscribeAll(async (event) => {
252
- const repository = typeof event.data?.repository === 'string' ? event.data.repository : undefined;
253
- const prValue = Number(event.data?.prNumber);
254
- const prNumber = Number.isInteger(prValue) && prValue > 0 ? prValue : undefined;
255
205
  const routedWebhook = event.type === 'github.webhook' && event.data?.coordinatorRouted === true;
256
206
  const ingressWebhook = event.type === 'github.webhook' && event.data?.coordinatorIngress === true && !routedWebhook;
257
- if (event.type === 'schedule.tick' && repository && prNumber) {
258
- await this.scheduleSignalCoordinator.acceptTransport({ jobId: event.job_id, reason: this.signalReason(event), event, repository, prNumber });
207
+ if (event.type === 'schedule.tick') {
208
+ await this.scheduleSignalCoordinator.acceptTransport({ jobId: event.job_id, reason: this.signalReason(event), event });
259
209
  return;
260
210
  }
261
211
  if (ingressWebhook)
@@ -263,17 +213,16 @@ export class RepositoryWorkerPool {
263
213
  if (routedWebhook)
264
214
  return;
265
215
  const lifecycle = ['task.started', 'task.completed', 'task.failed', 'task.blocked', 'task.unblocked', 'task.cancelled', 'review.started', 'review.approved', 'review.changes_required', 'review.blocked'].includes(event.type);
266
- const wake = event.type === 'job.wake' || event.type === 'github.webhook' || event.type === 'review.created';
216
+ // GitHub transport is observational. It must never wake lifecycle execution.
217
+ if (event.type === 'github.webhook')
218
+ return;
219
+ const wake = event.type === 'job.wake' || event.type === 'review.created' || event.type === 'planner.completed';
267
220
  if (!lifecycle && !wake)
268
221
  return;
269
- if (repository && prNumber)
270
- this.jobTargets.set(event.job_id, { repository, prNumber });
271
222
  await this.coordinator.acceptTransport({
272
223
  jobId: event.job_id,
273
224
  reason: this.signalReason(event),
274
225
  event: lifecycle ? event : undefined,
275
- repository,
276
- prNumber,
277
226
  });
278
227
  });
279
228
  return async () => {
@@ -285,13 +234,13 @@ export class RepositoryWorkerPool {
285
234
  async scheduleTickSignal(bus, job, now = new Date()) {
286
235
  if (!job.schedule)
287
236
  return;
288
- const event = eventFor(job.id, 'scheduler', null, 'schedule.tick', 'queued', `Schedule tick for ${job.schedule.nextRunAt}`, 'orchestrator', { repository: job.repository, prNumber: job.prNumber, bucket: scheduleBucket(new Date(job.schedule.nextRunAt)), tickAt: now.toISOString() }, { agent: 'relay', component: 'scheduler-timer' });
237
+ const event = eventFor(job.id, 'scheduler', null, 'schedule.tick', 'queued', `Schedule tick for ${job.schedule.nextRunAt}`, 'orchestrator', { bucket: scheduleBucket(new Date(job.schedule.nextRunAt)), tickAt: now.toISOString() }, { agent: 'relay', component: 'scheduler-timer' });
289
238
  // Use the same coordinator-owned schedule path whether transport is available or not.
290
239
  if (bus) {
291
240
  await bus.publish(event);
292
241
  return;
293
242
  }
294
- await this.scheduleSignalCoordinator.accept({ jobId: job.id, reason: this.signalReason(event), event, repository: job.repository, prNumber: job.prNumber });
243
+ await this.scheduleSignalCoordinator.accept({ jobId: job.id, reason: this.signalReason(event), event });
295
244
  }
296
245
  async webhookSignal(_bus, _wake) {
297
246
  // Uncorrelated webhook wakeups are intentionally ignored. A caller must first
@@ -301,7 +250,7 @@ export class RepositoryWorkerPool {
301
250
  if (bus) {
302
251
  const source = { id: `github/${job.repository}/delivery/${wake.deliveryId}`, component: 'github-webhook' };
303
252
  if (wake.publication) {
304
- await bus.publish(eventFor(job.id, 'release', null, 'package.published', 'succeeded', `Published ${wake.publication.package}@${wake.publication.version}`, 'orchestrator', {
253
+ const publishEvent = eventFor(job.id, 'release', null, 'package.published', 'succeeded', `Published ${wake.publication.package}@${wake.publication.version}`, 'orchestrator', {
305
254
  deliveryId: wake.deliveryId,
306
255
  repository: job.repository,
307
256
  prNumber: job.prNumber,
@@ -309,14 +258,18 @@ export class RepositoryWorkerPool {
309
258
  version: wake.publication.version,
310
259
  registry: wake.publication.registry,
311
260
  sha: wake.publication.sha,
312
- }, source));
261
+ }, source);
262
+ publishEvent.event_id = 'github:' + job.id + ':' + wake.deliveryId + ':package.published';
263
+ await bus.publish(publishEvent);
313
264
  }
314
265
  if (wake.publishFailure) {
315
- await bus.publish(eventFor(job.id, 'release', null, 'package.publish_failed', 'failed', `Publish workflow ${wake.publishFailure.runId} failed: ${wake.publishFailure.stage}`, 'user', { ...wake.publishFailure, repository: job.repository, prNumber: job.prNumber, deliveryId: wake.deliveryId }, source));
266
+ const failedEvent = eventFor(job.id, 'release', null, 'package.publish_failed', 'failed', `Publish workflow ${wake.publishFailure.runId} failed: ${wake.publishFailure.stage}`, 'user', { ...wake.publishFailure, repository: job.repository, prNumber: job.prNumber, deliveryId: wake.deliveryId }, source);
267
+ failedEvent.event_id = 'github:' + job.id + ':' + wake.deliveryId + ':package.publish_failed';
268
+ await bus.publish(failedEvent);
316
269
  }
317
270
  await bus.publish(eventFor(job.id, 'github', null, 'github.webhook', 'queued', `GitHub webhook ${wake.event}${wake.action ? ':' + wake.action : ''}`, 'orchestrator', { deliveryId: wake.deliveryId, event: wake.event, action: wake.action, repository: job.repository, prNumber: job.prNumber }, source));
318
271
  return;
319
272
  }
320
- await this.reconcileTarget(job.repository, job.prNumber, job.id, `github.webhook:${wake.event}${wake.action ? ':' + wake.action : ''}`);
273
+ // Without EventBus the GitHub observation stays in SQLite; it does not wake execution.
321
274
  }
322
275
  }
@@ -80,10 +80,12 @@ export class GitHubProjectionWriter {
80
80
  relay;
81
81
  github;
82
82
  onConflict;
83
- constructor(relay, github, onConflict) {
83
+ externalOnly;
84
+ constructor(relay, github, onConflict, externalOnly = false) {
84
85
  this.relay = relay;
85
86
  this.github = github;
86
87
  this.onConflict = onConflict;
88
+ this.externalOnly = externalOnly;
87
89
  }
88
90
  async flushOnce(limit = 50, now = new Date()) {
89
91
  const items = await this.relay.claimDueProjections(limit, now);
@@ -91,7 +93,22 @@ export class GitHubProjectionWriter {
91
93
  let deferred = 0;
92
94
  for (const item of items) {
93
95
  try {
96
+ if (this.externalOnly && !['merge_pull_request', 'close_pull_request', 'update_pr_body'].includes(item.operation)) {
97
+ // Drop legacy lifecycle projections; GitHub may not override SQLite truth.
98
+ await this.relay.completeProjection(item.id);
99
+ continue;
100
+ }
94
101
  await this.apply(item);
102
+ // Only a CONFIRMED external operation may create the corresponding
103
+ // fact; Graph Engine observes it and decides the local lifecycle.
104
+ if (item.operation === 'merge_pull_request' || item.operation === 'close_pull_request') {
105
+ const previous = await this.relay.loadPullRequestFacts();
106
+ await this.relay.savePullRequestFacts({
107
+ state: item.operation === 'merge_pull_request' ? 'MERGED' : 'CLOSED',
108
+ head: previous?.head ?? null,
109
+ observedAt: new Date().toISOString(),
110
+ });
111
+ }
95
112
  await this.relay.completeProjection(item.id);
96
113
  projected += 1;
97
114
  observability().record({
@@ -184,11 +201,11 @@ export class ProjectedDurableStore {
184
201
  relatedStore;
185
202
  projection;
186
203
  seededFacts;
187
- constructor(relay, github, seed = null, relatedStore, onConflict) {
204
+ constructor(relay, github, seed = null, relatedStore, onConflict, externalOnly = false) {
188
205
  this.relay = relay;
189
206
  this.github = github;
190
207
  this.relatedStore = relatedStore;
191
- this.projection = new GitHubProjectionWriter(relay, github, onConflict);
208
+ this.projection = new GitHubProjectionWriter(relay, github, onConflict, externalOnly);
192
209
  this.seededFacts = pullRequestFactsFromSeed(seed);
193
210
  if (this.seededFacts)
194
211
  void this.relay.savePullRequestFacts(this.seededFacts).catch(() => { });
@@ -136,7 +136,8 @@ export class ProjectionOutbox {
136
136
  }
137
137
  }
138
138
  resetProcessing(now) {
139
- this.db.prepare("UPDATE projection_outbox SET status='pending', updated_at=? WHERE status='processing'").run(now);
139
+ const cutoff = new Date(Date.parse(now) - 120_000).toISOString();
140
+ this.db.prepare("UPDATE projection_outbox SET status='pending', updated_at=? WHERE status='processing' AND updated_at<=?").run(now, cutoff);
140
141
  }
141
142
  enqueue(request, now) {
142
143
  const operationKey = logicalOperationKey(request.idempotencyKey);
@@ -179,6 +180,7 @@ export class ProjectionOutbox {
179
180
  const observedAt = now.toISOString();
180
181
  this.db.exec('BEGIN IMMEDIATE');
181
182
  try {
183
+ this.resetProcessing(observedAt); // Reclaim only stale claims; recover on each retry tick, not only on process restart.
182
184
  const rows = this.db.prepare(`
183
185
  SELECT ${OUTBOX_COLUMNS}
184
186
  FROM projection_outbox
@@ -0,0 +1,37 @@
1
+ /** Persist a webhook publication acknowledgement and its delivery ID in one transaction. */
2
+ export function recordPublicationInDatabase(db, jobId, deliveryId, publication, failure, observedAt = new Date().toISOString()) {
3
+ if (!publication && !failure)
4
+ return false;
5
+ db.exec('BEGIN IMMEDIATE');
6
+ try {
7
+ const inserted = db.prepare('INSERT OR IGNORE INTO publication_delivery(job_id,delivery_id,type,json,observed_at) VALUES(?,?,?,?,?)')
8
+ .run(jobId, deliveryId, publication ? 'package.published' : 'package.publish_failed', JSON.stringify(publication ?? failure), observedAt);
9
+ if (inserted.changes === 0) {
10
+ db.exec('COMMIT');
11
+ return false;
12
+ }
13
+ const previous = loadPublicationFromDatabase(db, jobId);
14
+ const isNewer = !previous || observedAt >= previous.updatedAt;
15
+ const next = isNewer ? {
16
+ latest: publication ?? previous?.latest ?? null,
17
+ failure: failure ?? (publication ? null : previous?.failure ?? null),
18
+ updatedAt: observedAt,
19
+ } : previous;
20
+ db.prepare('INSERT INTO publication_state(job_id,json) VALUES(?,?) ON CONFLICT(job_id) DO UPDATE SET json=excluded.json')
21
+ .run(jobId, JSON.stringify(next));
22
+ db.exec('COMMIT');
23
+ return true;
24
+ }
25
+ catch (error) {
26
+ db.exec('ROLLBACK');
27
+ throw error;
28
+ }
29
+ }
30
+ export function loadPublicationFromDatabase(db, jobId) {
31
+ const row = db.prepare('SELECT json FROM publication_state WHERE job_id=?').get(jobId);
32
+ return row ? JSON.parse(row.json) : null;
33
+ }
34
+ export function listPublicationEventsFromDatabase(db, jobId) {
35
+ const rows = db.prepare('SELECT delivery_id,type,json,observed_at FROM publication_delivery WHERE job_id=? ORDER BY observed_at,delivery_id').all(jobId);
36
+ return rows.map(row => ({ deliveryId: row.delivery_id, type: row.type, observedAt: row.observed_at, payload: JSON.parse(row.json) }));
37
+ }
@@ -0,0 +1,21 @@
1
+ import { applyTaskCancelAction } from './task-actions.js';
2
+ export async function cancelActiveTasks(store, jobId, reason, cancelPlanner, cancelExecutionGeneration) {
3
+ const job = await store.load(jobId);
4
+ for (const plan of (job.plans ?? []).filter(item => item.status === 'PLANNING')) {
5
+ cancelPlanner(plan.id);
6
+ plan.status = 'FAILED';
7
+ plan.error = reason;
8
+ plan.assessment = `Planning failed: ${reason}`;
9
+ plan.leaseOwner = null;
10
+ plan.leaseExpiresAt = null;
11
+ plan.executionId = null;
12
+ plan.updatedAt = new Date().toISOString();
13
+ await store.savePlan(plan);
14
+ }
15
+ for (const task of job.tasks.filter(item => item.kind !== 'planner' && !['SUCCEEDED', 'FAILED', 'UNBLOCKED', 'CANCELLED'].includes(item.state))) {
16
+ cancelExecutionGeneration(job.id, task.id, task.executionId ?? task.executionHistory?.at(-1)?.id);
17
+ cancelPlanner(task.id);
18
+ await applyTaskCancelAction(task, reason);
19
+ await store.saveTask(task);
20
+ }
21
+ }