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.
- package/README.md +24 -19
- package/dist/adapters.js +1 -1
- package/dist/cli-blocker.js +3 -5
- package/dist/cli-help.js +12 -10
- package/dist/cli-job-create.js +69 -0
- package/dist/cli-local-merge-config.js +9 -0
- package/dist/cli-support.js +25 -8
- package/dist/cli.js +66 -80
- package/dist/dashboard-html.js +1 -1
- package/dist/dashboard.js +1 -1
- package/dist/github-auth.js +4 -3
- package/dist/github-webhook.js +13 -5
- package/dist/local-authority-store.js +279 -0
- package/dist/local-git-merge.js +113 -0
- package/dist/local-job-path.js +7 -0
- package/dist/local-job-registry.js +23 -0
- package/dist/local-managed-store.js +21 -0
- package/dist/local-merge-delivery.js +52 -0
- package/dist/local-merge-lock.js +103 -0
- package/dist/no-github.js +11 -0
- package/dist/planned-review-head.js +7 -8
- package/dist/pool.js +72 -135
- package/dist/projected-store.js +20 -3
- package/dist/projection-outbox.js +3 -1
- package/dist/publication-store.js +37 -0
- package/dist/reconciler-review.js +25 -11
- package/dist/reconciler-runtime.js +15 -5
- package/dist/reconciler.js +33 -60
- package/dist/relay-store.js +108 -6
- package/dist/relayd.js +119 -124
- package/dist/runtime-status.js +8 -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-update.js +1 -1
- 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/package.json +5 -3
- package/workflow/job-lifecycle.mmd +19 -60
- 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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
|
9
|
-
'Distinguish code added by this implementation from pre-existing
|
|
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 {
|
|
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
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.
|
|
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(
|
|
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
|
-
|
|
30
|
-
|
|
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
|
-
|
|
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.
|
|
47
|
-
this.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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()))
|
|
89
|
+
const target = await this.localTarget(jobId);
|
|
90
|
+
if (!target)
|
|
97
91
|
return;
|
|
98
|
-
|
|
99
|
-
|
|
92
|
+
await runScheduledBucketFromStore(target.store, { jobId, bucket }, {
|
|
93
|
+
reconcile: async () => this.reconcileLocalJob(jobId, 'schedule.tick'),
|
|
94
|
+
idle: async () => { },
|
|
95
|
+
}, tickAt);
|
|
100
96
|
}
|
|
101
|
-
async
|
|
102
|
-
|
|
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
|
-
|
|
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
|
-
});
|
|
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
|
-
|
|
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;
|
|
118
|
+
if (signal.event.type === 'github.webhook')
|
|
119
|
+
continue;
|
|
120
|
+
const outcome = await reconciler.applyEvent(signal.event);
|
|
121
|
+
shouldReconcile ||= outcome.reconcile;
|
|
158
122
|
}
|
|
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
|
-
});
|
|
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.
|
|
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
|
|
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.
|
|
148
|
+
if (item.job.state !== 'ACTIVE') {
|
|
204
149
|
recovered.push(item);
|
|
205
150
|
continue;
|
|
206
151
|
}
|
|
207
|
-
this.
|
|
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
|
|
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
|
|
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
|
-
|
|
239
|
-
|
|
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.
|
|
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'
|
|
258
|
-
await this.scheduleSignalCoordinator.acceptTransport({ jobId: event.job_id, reason: this.signalReason(event), event
|
|
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
|
-
|
|
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', {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
257
|
+
// Without EventBus the GitHub observation stays in SQLite; it does not wake execution.
|
|
321
258
|
}
|
|
322
259
|
}
|
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
|
+
}
|