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.
- package/README.md +56 -20
- package/dist/adapters.js +39 -3
- package/dist/blockers.js +17 -4
- package/dist/cli-blocker.js +12 -30
- package/dist/cli-help.js +22 -17
- package/dist/cli-job-create.js +77 -0
- package/dist/cli-local-merge-config.js +11 -0
- package/dist/cli-projection.js +23 -55
- package/dist/cli-review.js +7 -24
- package/dist/cli-support.js +81 -8
- package/dist/cli-worker-task.js +63 -0
- package/dist/cli.js +223 -185
- package/dist/dashboard-html.js +15 -12
- package/dist/dashboard.js +27 -2
- package/dist/e2e-injection.js +76 -0
- package/dist/github-auth.js +4 -3
- package/dist/github-binding-order.js +15 -0
- package/dist/github-client.js +7 -2
- package/dist/github-pr-archive.js +22 -0
- package/dist/github-webhook.js +43 -10
- package/dist/job-actions.js +29 -0
- package/dist/local-authority-store.js +288 -0
- package/dist/local-commit-events.js +2 -0
- package/dist/local-git-merge.js +124 -0
- package/dist/local-job-path.js +7 -0
- package/dist/local-job-registry.js +29 -0
- package/dist/local-managed-store.js +21 -0
- package/dist/local-merge-delivery.js +53 -0
- package/dist/local-merge-lock.js +103 -0
- package/dist/local-merge-projection.js +60 -0
- package/dist/managed-worker-input.js +13 -6
- package/dist/manual-plan.js +29 -7
- package/dist/no-github.js +11 -0
- package/dist/planned-review-head.js +7 -8
- package/dist/planner-runtime.js +182 -41
- package/dist/planner.js +35 -2
- package/dist/pool.js +89 -136
- package/dist/projected-store.js +20 -3
- package/dist/projection-outbox.js +3 -1
- package/dist/publication-store.js +37 -0
- package/dist/reconciler-cancellation.js +21 -0
- package/dist/reconciler-review.js +99 -29
- package/dist/reconciler-runtime.js +72 -35
- package/dist/reconciler.js +124 -85
- package/dist/relay-store.js +108 -6
- package/dist/relayd-control.js +184 -0
- package/dist/relayd-dashboard-actions.js +108 -0
- package/dist/relayd-utilities.js +107 -0
- package/dist/relayd.js +304 -299
- package/dist/review-generation-fence.js +12 -0
- package/dist/runtime-status-events.js +21 -0
- package/dist/runtime-status.js +21 -7
- package/dist/scheduled-jobs.js +43 -36
- package/dist/service-external-actions.js +27 -0
- package/dist/service-publication.js +29 -0
- package/dist/store-github.js +17 -6
- package/dist/task-actions.js +59 -1
- package/dist/task-update.js +1 -1
- package/dist/troubleshooter.js +21 -2
- package/dist/unblock.js +47 -15
- package/dist/worker-rejection.js +11 -0
- package/dist/workflow/contracts.js +10 -1
- package/dist/workflow/definitions-v2.js +2 -0
- package/dist/workflow/job-reconcile-v2.js +9 -60
- package/dist/workflow/runtime.js +18 -3
- package/dist/workflow-effect-failures.js +30 -0
- package/dist/workspace.js +95 -6
- package/package.json +6 -3
- package/skills/agents-relay/agents/planner.agent.md +3 -0
- package/skills/agents-relay/contracts/orchestrator-workflows.md +4 -7
- package/workflow/job-lifecycle.mmd +19 -60
- package/workflow/review-lifecycle.mmd +5 -8
package/dist/pool.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { listManagedGitHubJobs } from './store.js';
|
|
2
2
|
import { eventFor } from './events.js';
|
|
3
|
-
import {
|
|
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.
|
|
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(
|
|
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
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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.
|
|
47
|
-
this.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
90
|
-
|
|
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
|
-
|
|
99
|
-
|
|
106
|
+
await runScheduledBucketFromStore(target.store, { jobId, bucket }, {
|
|
107
|
+
reconcile: async () => this.reconcileLocalJob(jobId, 'schedule.tick'),
|
|
108
|
+
idle: async () => { },
|
|
109
|
+
}, tickAt);
|
|
100
110
|
}
|
|
101
|
-
async
|
|
102
|
-
|
|
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
|
-
|
|
106
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
160
|
-
|
|
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(
|
|
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.
|
|
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
|
|
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.
|
|
164
|
+
if (item.job.state !== 'ACTIVE') {
|
|
204
165
|
recovered.push(item);
|
|
205
166
|
continue;
|
|
206
167
|
}
|
|
207
|
-
this.
|
|
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
|
|
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.
|
|
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
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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.
|
|
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'
|
|
258
|
-
await this.scheduleSignalCoordinator.acceptTransport({ jobId: event.job_id, reason: this.signalReason(event), event
|
|
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
|
-
|
|
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', {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
273
|
+
// Without EventBus the GitHub observation stays in SQLite; it does not wake execution.
|
|
321
274
|
}
|
|
322
275
|
}
|
package/dist/projected-store.js
CHANGED
|
@@ -80,10 +80,12 @@ export class GitHubProjectionWriter {
|
|
|
80
80
|
relay;
|
|
81
81
|
github;
|
|
82
82
|
onConflict;
|
|
83
|
-
|
|
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
|
-
|
|
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
|
+
}
|