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,103 @@
1
+ import { mkdirSync, readFileSync, rmSync, writeFileSync, statSync, renameSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { createHash } from 'node:crypto';
4
+ import { hostname } from 'node:os';
5
+ const sleep = (ms) => {
6
+ // Synchronous delivery API; Atomics.wait only blocks this worker's thread.
7
+ const wait = new Int32Array(new SharedArrayBuffer(4));
8
+ Atomics.wait(wait, 0, 0, ms);
9
+ };
10
+ function processAlive(pid) {
11
+ try {
12
+ process.kill(pid, 0);
13
+ return true;
14
+ }
15
+ catch (error) {
16
+ return error.code !== 'ESRCH';
17
+ }
18
+ }
19
+ /** Cross-process atomic directory lock in the shared Git common directory. */
20
+ export function withLocalMergeLock(commonDir, targetRef, action, timeoutMs = 15000) {
21
+ const digest = createHash('sha256').update(targetRef).digest('hex').slice(0, 24);
22
+ const path = join(commonDir, 'agents-relay-merge-' + digest + '.lockdir');
23
+ const ownerPath = join(path, 'owner.json');
24
+ const recoveryGuard = path + '.recovery';
25
+ const guardOwnerPath = join(recoveryGuard, 'owner.json');
26
+ const incompleteGraceMs = 3000;
27
+ const deadline = Date.now() + timeoutMs;
28
+ const owner = { pid: process.pid, host: hostname(), startedAt: new Date().toISOString(), targetRef };
29
+ while (true) {
30
+ try {
31
+ mkdirSync(path);
32
+ writeFileSync(ownerPath, JSON.stringify(owner), { flag: 'wx', mode: 0o600 });
33
+ break;
34
+ }
35
+ catch (error) {
36
+ if (error.code !== 'EEXIST')
37
+ throw error;
38
+ // Reap a crashed recovery owner, or a crash between mkdir and owner write.
39
+ // Renaming the orphan into a private quarantine keeps cleanup out of the
40
+ // active path. A grace period protects slow owner-file initialization.
41
+ const orphaned = (directory, metadata) => {
42
+ try {
43
+ const age = Date.now() - statSync(directory).mtimeMs;
44
+ if (age < incompleteGraceMs)
45
+ return false;
46
+ try {
47
+ const previous = JSON.parse(readFileSync(metadata, 'utf8'));
48
+ return previous.host === hostname() && Number.isInteger(previous.pid)
49
+ && !processAlive(previous.pid);
50
+ }
51
+ catch {
52
+ return true;
53
+ }
54
+ }
55
+ catch {
56
+ return false;
57
+ }
58
+ };
59
+ if (orphaned(recoveryGuard, guardOwnerPath)) {
60
+ const quarantine = recoveryGuard + '.orphan-' + process.pid + '-' + createHash('sha256').update(owner.startedAt).digest('hex').slice(0, 10);
61
+ try {
62
+ renameSync(recoveryGuard, quarantine);
63
+ rmSync(quarantine, { recursive: true, force: true });
64
+ }
65
+ catch { /* another recovery contender may already have moved it */ }
66
+ }
67
+ let guarded = false;
68
+ try {
69
+ mkdirSync(recoveryGuard);
70
+ guarded = true;
71
+ writeFileSync(guardOwnerPath, JSON.stringify(owner), { flag: 'wx', mode: 0o600 });
72
+ if (orphaned(path, ownerPath))
73
+ rmSync(path, { recursive: true, force: true });
74
+ }
75
+ catch { /* A live lock or incomplete initialization must be preserved. */ }
76
+ finally {
77
+ if (guarded) {
78
+ try {
79
+ const current = JSON.parse(readFileSync(guardOwnerPath, 'utf8'));
80
+ if (current.pid === owner.pid && current.startedAt === owner.startedAt && current.host === owner.host)
81
+ rmSync(recoveryGuard, { recursive: true, force: true });
82
+ }
83
+ catch { /* keep unknown guard */ }
84
+ }
85
+ }
86
+ if (Date.now() >= deadline)
87
+ throw new Error('Local merge target locked: ' + targetRef);
88
+ sleep(50);
89
+ }
90
+ }
91
+ try {
92
+ return action();
93
+ }
94
+ finally {
95
+ // Never release a lock now owned by a different process.
96
+ try {
97
+ const current = JSON.parse(readFileSync(ownerPath, 'utf8'));
98
+ if (current.pid === owner.pid && current.host === owner.host && current.startedAt === owner.startedAt)
99
+ rmSync(path, { recursive: true, force: true });
100
+ }
101
+ catch { /* preserve any unknown lock */ }
102
+ }
103
+ }
@@ -0,0 +1,11 @@
1
+ import { GhApiClient } from './github-client.js';
2
+ /** No GitHub transport can escape the disabled adapter. */
3
+ export class DisabledGitHubClient extends GhApiClient {
4
+ async request() { throw new Error('GitHub disabled by --no-github'); }
5
+ async graphql() { throw new Error('GitHub disabled by --no-github'); }
6
+ async rateLimitSnapshot() { throw new Error('GitHub disabled by --no-github'); }
7
+ async currentLogin() { throw new Error('GitHub disabled by --no-github'); }
8
+ }
9
+ export function noGitHubMode(args = process.argv.slice(2)) {
10
+ return args.includes('--no-github') || process.argv.includes('--no-github') || process.env.AGENTS_RELAY_NO_GITHUB === '1';
11
+ }
@@ -1,14 +1,13 @@
1
1
  /** A planner-created dependent review must freeze its head only after implementation completes. */
2
- export async function bindPlannedReviewHead(store, task) {
3
- const current = await store.pullRequestHead?.();
4
- if (!current?.sha)
5
- throw new Error(`Review ${task.id} requires current PR head SHA`);
6
- task.reviewHeadSha = current.sha;
2
+ export async function bindPlannedReviewHead(store, task, localHeadSha) {
3
+ if (!localHeadSha)
4
+ throw new Error(`Review ${task.id} requires current local Git head SHA`);
5
+ task.reviewHeadSha = localHeadSha;
7
6
  const rule = [
8
- 'Review the current immutable PR head as a PRE-MERGE approval gate.',
9
- 'Distinguish code added by this implementation from pre-existing PR baseline/seed files present before the Task started.',
7
+ 'Review the current immutable local source head as a PRE-MERGE approval gate.',
8
+ 'Distinguish code added by this implementation from pre-existing local baseline/seed files present before the Task started.',
10
9
  'Preserved baseline files are not automatically extra changes merely because they differ from main; still review them for real risks.',
11
- 'Do not require PR MERGED, Job COMPLETED, or post-merge E2E evidence before review approval:',
10
+ 'Do not require a remote PR MERGED, Job COMPLETED, or post-merge E2E evidence before review approval:',
12
11
  'those events must follow approval and are verified by the independent E2E runner.',
13
12
  ].join(' ');
14
13
  if (!task.input.includes(rule))
package/dist/pool.js CHANGED
@@ -1,12 +1,12 @@
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
7
  const RANK = { P0: 0, P1: 1, P2: 2, P3: 3 };
8
8
  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); }
9
+ export function runnableManagedJobs(items) { return items.filter(item => item.job.state === 'ACTIVE').map(item => item.job).sort(compareJobPriority); }
10
10
  export function needsLifecycleReconciliation(job, now = Date.now()) {
11
11
  // DRAFT create-only Jobs stay dormant until a caller explicitly creates work.
12
12
  if (job.tasks.length === 0)
@@ -15,38 +15,28 @@ export function needsLifecycleReconciliation(job, now = Date.now()) {
15
15
  return true;
16
16
  return job.tasks.some(task => ['RUNNING', 'QUEUED'].includes(task.state) && task.executionId !== null && task.leaseExpiresAt !== null && new Date(task.leaseExpiresAt).getTime() <= now);
17
17
  }
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); }
18
+ export function terminalManagedJobs(_items) { return []; }
19
19
  export async function aggregateManagedGitHubJobs(client, repositories, trustedAuthors) {
20
20
  const uniqueRepositories = [...new Set(repositories)];
21
21
  const groups = await Promise.all(uniqueRepositories.map(repository => listManagedGitHubJobs(client, repository, trustedAuthors)));
22
22
  return groups.flat().sort((a, b) => b.job.updatedAt.localeCompare(a.job.updatedAt) || b.job.prNumber - a.job.prNumber);
23
23
  }
24
24
  export class RepositoryWorkerPool {
25
- client;
26
- trustedAuthors;
27
25
  concurrency;
28
26
  makeReconciler;
29
- isRateLimited;
30
- refreshWebhookSnapshot;
31
- makeStore;
27
+ localInventory;
28
+ resolveLocalJob;
32
29
  running = false;
33
30
  pending = false;
34
31
  reconcileLocks = new Map();
35
32
  coordinator;
36
- githubSignalCoordinator;
37
33
  scheduleSignalCoordinator;
38
34
  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;
35
+ constructor(concurrency, makeReconciler, localInventory, resolveLocalJob) {
44
36
  this.concurrency = concurrency;
45
37
  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)];
38
+ this.localInventory = localInventory;
39
+ this.resolveLocalJob = resolveLocalJob;
50
40
  const reportCoordinatorError = (error, signal) => {
51
41
  observability().record({
52
42
  stage: 'reconcile', event: 'reconcile.coordinator_failed', level: 'error', outcome: 'failed',
@@ -56,10 +46,17 @@ export class RepositoryWorkerPool {
56
46
  });
57
47
  };
58
48
  this.coordinator = new ReconcileCoordinator((jobId, signals) => this.consumeSignals(jobId, signals), reportCoordinatorError);
59
- this.githubSignalCoordinator = new ReconcileCoordinator((_key, signals) => this.consumeGitHubSignals(signals), reportCoordinatorError);
60
49
  this.scheduleSignalCoordinator = new ReconcileCoordinator((jobId, signals) => this.consumeScheduleSignals(jobId, signals), reportCoordinatorError);
61
50
  }
62
- repositories;
51
+ async localTarget(jobId) {
52
+ return this.resolveLocalJob(jobId);
53
+ }
54
+ async requireLocalStore(jobId) {
55
+ const resolved = await this.localTarget(jobId);
56
+ if (!resolved)
57
+ throw new Error('Authoritative SQLite Job is not registered: ' + jobId);
58
+ return resolved.store;
59
+ }
63
60
  async reconcileExclusive(jobId, work) {
64
61
  while (this.reconcileLocks.has(jobId))
65
62
  await this.reconcileLocks.get(jobId);
@@ -84,107 +81,55 @@ export class RepositoryWorkerPool {
84
81
  return `event.${event.type}`;
85
82
  }
86
83
  async consumeScheduleSignals(jobId, signals) {
87
- if (this.isRateLimited() || signals.length === 0)
84
+ const event = [...signals].reverse().find(item => item.event?.type === 'schedule.tick')?.event;
85
+ const bucket = typeof event?.data?.bucket === 'string' ? event.data.bucket : null;
86
+ const tickAt = new Date(typeof event?.data?.tickAt === 'string' ? event.data.tickAt : event?.timestamp ?? Date.now());
87
+ if (!bucket || Number.isNaN(tickAt.getTime()))
88
88
  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()))
89
+ const target = await this.localTarget(jobId);
90
+ if (!target)
97
91
  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));
92
+ await runScheduledBucketFromStore(target.store, { jobId, bucket }, {
93
+ reconcile: async () => this.reconcileLocalJob(jobId, 'schedule.tick'),
94
+ idle: async () => { },
95
+ }, tickAt);
100
96
  }
101
- async consumeGitHubSignals(_signals) {
102
- // GitHub webhooks are fact-only inputs. They must never trigger a GitHub reread.
97
+ async reconcileLocalJob(jobId, reason = 'local.request') {
98
+ const target = await this.localTarget(jobId);
99
+ if (!target)
100
+ throw new Error('Local job not found: ' + jobId);
101
+ const reconciler = this.makeReconciler(target.store, Math.max(1, this.concurrency));
102
+ await this.reconcileExclusive(jobId, async () => {
103
+ await withObservabilityCorrelation({ jobId, repository: target.job.repository }, () => reconciler.reconcile(jobId));
104
+ });
105
+ void reason;
103
106
  }
104
107
  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
- });
108
+ const target = await this.localTarget(jobId);
109
+ if (!target)
118
110
  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));
111
+ const reconciler = this.makeReconciler(target.store, Math.max(1, this.concurrency));
132
112
  let shouldReconcile = false;
133
- const reasons = [];
134
113
  for (const signal of signals) {
135
- reasons.push(signal.reason);
136
114
  if (!signal.event) {
137
115
  shouldReconcile = true;
138
116
  continue;
139
117
  }
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;
118
+ if (signal.event.type === 'github.webhook')
119
+ continue;
120
+ const outcome = await reconciler.applyEvent(signal.event);
121
+ shouldReconcile ||= outcome.reconcile;
158
122
  }
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
- });
123
+ if (shouldReconcile)
124
+ await this.reconcileLocalJob(jobId, 'local.signal');
169
125
  }
170
126
  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
127
  const jobs = runnableManagedJobs(discovered);
183
128
  let capacity = Math.max(1, this.concurrency);
184
129
  for (const job of jobs) {
185
130
  if (capacity <= 0)
186
131
  break;
187
- const store = this.makeStore(job.repository, job.prNumber);
132
+ const store = await this.requireLocalStore(job.id);
188
133
  await store.hydrate?.(job);
189
134
  const ready = job.tasks.filter(task => (task.state === 'QUEUED' && task.executionId === null) || (task.state === 'DRAFT' && task.dependencies.length > 0)).length;
190
135
  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 +137,7 @@ export class RepositoryWorkerPool {
192
137
  continue;
193
138
  const allowance = Math.max(1, Math.min(capacity, ready || (expiredRunning ? 1 : 0) || 1));
194
139
  await this.reconcileExclusive(job.id, async () => {
195
- await withGitHubRequestContext(this.client, 'pool.discovered-reconcile', () => this.makeReconciler(store, allowance).reconcile(job.id));
140
+ await this.makeReconciler(store, allowance).reconcile(job.id);
196
141
  });
197
142
  capacity -= allowance;
198
143
  }
@@ -200,26 +145,23 @@ export class RepositoryWorkerPool {
200
145
  async reconcileKnown(discovered) {
201
146
  const recovered = [];
202
147
  for (const item of discovered) {
203
- if (item.githubState !== 'OPEN' || item.job.state !== 'ACTIVE') {
148
+ if (item.job.state !== 'ACTIVE') {
204
149
  recovered.push(item);
205
150
  continue;
206
151
  }
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);
152
+ const store = await this.requireLocalStore(item.job.id);
209
153
  await store.hydrate?.(item.job);
210
154
  const reconciler = this.makeReconciler(store, 0);
211
155
  let recoveredJob = item.job;
212
156
  if (typeof reconciler.recoverStartupOrphans === 'function')
213
157
  await this.reconcileExclusive(item.job.id, async () => {
214
- recoveredJob = await withGitHubRequestContext(this.client, 'pool.startup-orphan-recovery', () => reconciler.recoverStartupOrphans(item.job.id));
158
+ recoveredJob = await reconciler.recoverStartupOrphans(item.job.id);
215
159
  });
216
160
  recovered.push({ ...item, job: recoveredJob });
217
161
  }
218
162
  await this.reconcileDiscovered(recovered);
219
163
  }
220
164
  async reconcile() {
221
- if (this.isRateLimited())
222
- return;
223
165
  if (this.running) {
224
166
  this.pending = true;
225
167
  return;
@@ -228,34 +170,26 @@ export class RepositoryWorkerPool {
228
170
  try {
229
171
  do {
230
172
  this.pending = false;
231
- await this.reconcileDiscovered(await withGitHubRequestContext(this.client, 'pool.workspace-scan', () => aggregateManagedGitHubJobs(this.client, this.repositories, this.trustedAuthors)));
173
+ await this.reconcileDiscovered(await this.localInventory());
232
174
  } while (this.pending);
233
175
  }
234
176
  finally {
235
177
  this.running = false;
236
178
  }
237
179
  }
238
- seedTargetSnapshot(repository, prNumber, jobId, seed) {
239
- if (!this.repositories.includes(repository))
180
+ async reconcileTarget(_repository, _prNumber, jobId, reason = 'target.request') {
181
+ // Transport metadata is never used for routing; reject observational wakeups.
182
+ if (reason.startsWith('github.webhook:'))
240
183
  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))
245
- return;
246
- this.jobTargets.set(jobId, { repository, prNumber });
247
- await this.coordinator.accept({ jobId, reason, repository, prNumber });
184
+ await this.coordinator.accept({ jobId, reason });
248
185
  }
249
186
  async watch(bus) {
250
187
  this.watchedBus = bus;
251
188
  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
189
  const routedWebhook = event.type === 'github.webhook' && event.data?.coordinatorRouted === true;
256
190
  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 });
191
+ if (event.type === 'schedule.tick') {
192
+ await this.scheduleSignalCoordinator.acceptTransport({ jobId: event.job_id, reason: this.signalReason(event), event });
259
193
  return;
260
194
  }
261
195
  if (ingressWebhook)
@@ -263,17 +197,16 @@ export class RepositoryWorkerPool {
263
197
  if (routedWebhook)
264
198
  return;
265
199
  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';
200
+ // GitHub transport is observational. It must never wake lifecycle execution.
201
+ if (event.type === 'github.webhook')
202
+ return;
203
+ const wake = event.type === 'job.wake' || event.type === 'review.created';
267
204
  if (!lifecycle && !wake)
268
205
  return;
269
- if (repository && prNumber)
270
- this.jobTargets.set(event.job_id, { repository, prNumber });
271
206
  await this.coordinator.acceptTransport({
272
207
  jobId: event.job_id,
273
208
  reason: this.signalReason(event),
274
209
  event: lifecycle ? event : undefined,
275
- repository,
276
- prNumber,
277
210
  });
278
211
  });
279
212
  return async () => {
@@ -285,13 +218,13 @@ export class RepositoryWorkerPool {
285
218
  async scheduleTickSignal(bus, job, now = new Date()) {
286
219
  if (!job.schedule)
287
220
  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' });
221
+ 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
222
  // Use the same coordinator-owned schedule path whether transport is available or not.
290
223
  if (bus) {
291
224
  await bus.publish(event);
292
225
  return;
293
226
  }
294
- await this.scheduleSignalCoordinator.accept({ jobId: job.id, reason: this.signalReason(event), event, repository: job.repository, prNumber: job.prNumber });
227
+ await this.scheduleSignalCoordinator.accept({ jobId: job.id, reason: this.signalReason(event), event });
295
228
  }
296
229
  async webhookSignal(_bus, _wake) {
297
230
  // Uncorrelated webhook wakeups are intentionally ignored. A caller must first
@@ -301,7 +234,7 @@ export class RepositoryWorkerPool {
301
234
  if (bus) {
302
235
  const source = { id: `github/${job.repository}/delivery/${wake.deliveryId}`, component: 'github-webhook' };
303
236
  if (wake.publication) {
304
- await bus.publish(eventFor(job.id, 'release', null, 'package.published', 'succeeded', `Published ${wake.publication.package}@${wake.publication.version}`, 'orchestrator', {
237
+ const publishEvent = eventFor(job.id, 'release', null, 'package.published', 'succeeded', `Published ${wake.publication.package}@${wake.publication.version}`, 'orchestrator', {
305
238
  deliveryId: wake.deliveryId,
306
239
  repository: job.repository,
307
240
  prNumber: job.prNumber,
@@ -309,14 +242,18 @@ export class RepositoryWorkerPool {
309
242
  version: wake.publication.version,
310
243
  registry: wake.publication.registry,
311
244
  sha: wake.publication.sha,
312
- }, source));
245
+ }, source);
246
+ publishEvent.event_id = 'github:' + job.id + ':' + wake.deliveryId + ':package.published';
247
+ await bus.publish(publishEvent);
313
248
  }
314
249
  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));
250
+ 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);
251
+ failedEvent.event_id = 'github:' + job.id + ':' + wake.deliveryId + ':package.publish_failed';
252
+ await bus.publish(failedEvent);
316
253
  }
317
254
  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
255
  return;
319
256
  }
320
- await this.reconcileTarget(job.repository, job.prNumber, job.id, `github.webhook:${wake.event}${wake.action ? ':' + wake.action : ''}`);
257
+ // Without EventBus the GitHub observation stays in SQLite; it does not wake execution.
321
258
  }
322
259
  }
@@ -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
+ }