agents-relay 1.0.0
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/.github/workflows/publish.yml +91 -0
- package/AGENTS.md +16 -0
- package/LICENSE +21 -0
- package/README.md +102 -0
- package/dist/adapters.js +311 -0
- package/dist/cli.js +455 -0
- package/dist/continuation.js +21 -0
- package/dist/dashboard.js +446 -0
- package/dist/events.js +36 -0
- package/dist/github-auth.js +34 -0
- package/dist/github-webhook.js +47 -0
- package/dist/markers.js +42 -0
- package/dist/planner.js +172 -0
- package/dist/pool.js +98 -0
- package/dist/reconciler.js +434 -0
- package/dist/registry.js +27 -0
- package/dist/relayd.js +177 -0
- package/dist/scheduler.js +49 -0
- package/dist/store.js +586 -0
- package/dist/types.js +6 -0
- package/dist/usage.js +370 -0
- package/dist/workspace.js +76 -0
- package/docs/agent-network.md +34 -0
- package/docs/architecture.md +120 -0
- package/docs/autonomous-objective-jobs.md +121 -0
- package/docs/example.md +30 -0
- package/docs/github-app-rate-limit.md +124 -0
- package/docs/service.md +43 -0
- package/pack.json +326 -0
- package/package.json +14 -0
- package/scripts/npm-version.mjs +11 -0
- package/skills/agents-relay/SKILL.md +77 -0
- package/skills/agents-relay/agents/planner.agent.md +28 -0
- package/src/adapters.ts +231 -0
- package/src/cli.ts +324 -0
- package/src/continuation.ts +6 -0
- package/src/dashboard.ts +421 -0
- package/src/events.ts +25 -0
- package/src/github-auth.ts +35 -0
- package/src/github-webhook.ts +37 -0
- package/src/markers.ts +33 -0
- package/src/planner.ts +150 -0
- package/src/pool.ts +87 -0
- package/src/reconciler.ts +235 -0
- package/src/registry.ts +35 -0
- package/src/relayd.ts +137 -0
- package/src/scheduler.ts +27 -0
- package/src/store.ts +526 -0
- package/src/types.ts +45 -0
- package/src/usage.ts +385 -0
- package/src/workspace.ts +62 -0
- package/test/adapters.test.js +303 -0
- package/test/autonomous.test.js +119 -0
- package/test/core.test.js +363 -0
- package/test/dashboard.test.js +178 -0
- package/test/github-auth.test.js +51 -0
- package/test/github-webhook.test.js +21 -0
- package/test/service.test.js +116 -0
- package/test/store.test.js +390 -0
- package/test/usage.test.js +88 -0
- package/test/workspace.test.js +95 -0
- package/tsconfig.json +4 -0
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { schedule, transitionTask } from './scheduler.js';
|
|
3
|
+
import { eventFor } from './events.js';
|
|
4
|
+
import { parsePlannerResult, plannerInput } from './planner.js';
|
|
5
|
+
const PRIORITY_RANK = { P0: 0, P1: 1, P2: 2, P3: 3 };
|
|
6
|
+
export function compareTaskPriority(a, b, jobPriority = 'P2') { const ar = PRIORITY_RANK[a.priority ?? jobPriority]; const br = PRIORITY_RANK[b.priority ?? jobPriority]; return ar - br || a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id); }
|
|
7
|
+
export function managedWorkerInput(job, task) {
|
|
8
|
+
if (!['codex', 'chatgpt'].includes(task.adapter) || !job.repository || job.prNumber <= 0)
|
|
9
|
+
return task.input;
|
|
10
|
+
const prUrl = `https://github.com/${job.repository}/pull/${job.prNumber}`;
|
|
11
|
+
return `[Agents Relay managed task]\nPR: ${prUrl}\nJob: ${job.id}\nTask: ${task.id}\nParent task: ${task.parentTaskId ?? 'none'}\nProject: ${task.projectName ?? 'unspecified'}\n\n${task.input}`;
|
|
12
|
+
}
|
|
13
|
+
export class Reconciler {
|
|
14
|
+
store;
|
|
15
|
+
options;
|
|
16
|
+
live = new Map();
|
|
17
|
+
plannerLive = new Map();
|
|
18
|
+
continuationLive = new Set();
|
|
19
|
+
reconciling = false;
|
|
20
|
+
constructor(store, options) {
|
|
21
|
+
this.store = store;
|
|
22
|
+
this.options = options;
|
|
23
|
+
}
|
|
24
|
+
async watch(jobId) { if (!this.options.eventBus)
|
|
25
|
+
return async () => { }; try {
|
|
26
|
+
return await this.options.eventBus.subscribe(jobId, async (event) => { if (event.type !== 'job.wake' && event.type !== 'github.webhook')
|
|
27
|
+
return; await this.reconcile(jobId); });
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
await this.reportTransportFailure(error);
|
|
31
|
+
return async () => { };
|
|
32
|
+
} }
|
|
33
|
+
async idle() { while (this.live.size > 0 || this.plannerLive.size > 0 || this.continuationLive.size > 0 || this.reconciling)
|
|
34
|
+
await new Promise(resolve => setTimeout(resolve, 25)); }
|
|
35
|
+
async wake(jobId) { return this.reconcile(jobId); }
|
|
36
|
+
async reconcile(jobId) {
|
|
37
|
+
if (this.reconciling)
|
|
38
|
+
return this.store.load(jobId);
|
|
39
|
+
this.reconciling = true;
|
|
40
|
+
try {
|
|
41
|
+
const job = await this.store.load(jobId);
|
|
42
|
+
const previousState = job.state;
|
|
43
|
+
const prState = await this.store.pullRequestState?.() ?? 'OPEN';
|
|
44
|
+
const now = (this.options.now ?? new Date()).getTime();
|
|
45
|
+
await this.recoverFinishedWorkers(job);
|
|
46
|
+
if (prState !== 'OPEN') {
|
|
47
|
+
const reason = prState === 'MERGED' ? 'Pull request merged' : 'Pull request closed';
|
|
48
|
+
for (const task of job.tasks.filter(item => !['SUCCEEDED', 'FAILED', 'CANCELLED'].includes(item.state))) {
|
|
49
|
+
this.live.get(task.id)?.execution.cancel();
|
|
50
|
+
this.plannerLive.get(task.id)?.controller.abort();
|
|
51
|
+
transitionTask(task, 'CANCELLED');
|
|
52
|
+
task.error = reason;
|
|
53
|
+
task.leaseOwner = null;
|
|
54
|
+
task.leaseExpiresAt = null;
|
|
55
|
+
await this.store.saveTask(task);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
await this.recoverLostExecutions(job, now);
|
|
59
|
+
for (const task of job.tasks)
|
|
60
|
+
if (task.state === 'CANCELLED') {
|
|
61
|
+
this.live.get(task.id)?.execution.cancel();
|
|
62
|
+
this.plannerLive.get(task.id)?.controller.abort();
|
|
63
|
+
}
|
|
64
|
+
let scheduled = schedule(await this.store.load(jobId));
|
|
65
|
+
if (prState === 'OPEN' && scheduled.executionMode === 'autonomous') {
|
|
66
|
+
scheduled = schedule(await this.maybeCreatePlanner(scheduled));
|
|
67
|
+
}
|
|
68
|
+
if (prState === 'MERGED') {
|
|
69
|
+
scheduled.state = 'COMPLETED';
|
|
70
|
+
scheduled.updatedAt = new Date().toISOString();
|
|
71
|
+
}
|
|
72
|
+
if (prState === 'CLOSED') {
|
|
73
|
+
scheduled.state = 'CANCELLED';
|
|
74
|
+
scheduled.updatedAt = new Date().toISOString();
|
|
75
|
+
}
|
|
76
|
+
if (scheduled.state !== previousState)
|
|
77
|
+
await this.store.saveJob(scheduled);
|
|
78
|
+
if (prState === 'OPEN') {
|
|
79
|
+
for (const task of scheduled.tasks.filter(item => item.state === 'SUCCEEDED' && (item.continuation || scheduled.continuation) && !item.continuationDeliveredAt))
|
|
80
|
+
this.detach(this.deliverContinuation(scheduled, task), scheduled.id, task.id, 'continuation');
|
|
81
|
+
const capacity = Math.max(0, (this.options.maxConcurrent ?? 4) - this.live.size - this.plannerLive.size);
|
|
82
|
+
for (const task of scheduled.tasks.filter(t => t.state === 'READY').sort((a, b) => compareTaskPriority(a, b, scheduled.priority ?? 'P2')).slice(0, capacity)) {
|
|
83
|
+
if (task.kind === 'planner')
|
|
84
|
+
await this.launchPlanner(scheduled, task);
|
|
85
|
+
else
|
|
86
|
+
await this.launch(scheduled, task);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const finalState = schedule(await this.store.load(jobId));
|
|
90
|
+
if (prState === 'MERGED') {
|
|
91
|
+
finalState.state = 'COMPLETED';
|
|
92
|
+
finalState.updatedAt = new Date().toISOString();
|
|
93
|
+
}
|
|
94
|
+
if (prState === 'CLOSED') {
|
|
95
|
+
finalState.state = 'CANCELLED';
|
|
96
|
+
finalState.updatedAt = new Date().toISOString();
|
|
97
|
+
}
|
|
98
|
+
if (finalState.state !== scheduled.state || finalState.updatedAt !== scheduled.updatedAt)
|
|
99
|
+
await this.store.saveJob(finalState);
|
|
100
|
+
if (finalState.state === 'COMPLETED' || prState !== 'OPEN')
|
|
101
|
+
await this.cleanupThreads(finalState);
|
|
102
|
+
return this.store.load(jobId);
|
|
103
|
+
}
|
|
104
|
+
finally {
|
|
105
|
+
this.reconciling = false;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
async recoverLostExecutions(job, now) {
|
|
109
|
+
for (const task of job.tasks.filter(item => item.state === 'RUNNING')) {
|
|
110
|
+
const expired = task.leaseExpiresAt !== null && new Date(task.leaseExpiresAt).getTime() <= now;
|
|
111
|
+
const ownerLost = task.leaseOwner !== null && task.leaseOwner !== this.options.owner;
|
|
112
|
+
if (!expired && !ownerLost)
|
|
113
|
+
continue;
|
|
114
|
+
this.live.get(task.id)?.execution.cancel();
|
|
115
|
+
const planner = this.plannerLive.get(task.id);
|
|
116
|
+
if (planner) {
|
|
117
|
+
clearTimeout(planner.timer);
|
|
118
|
+
planner.controller.abort();
|
|
119
|
+
this.plannerLive.delete(task.id);
|
|
120
|
+
}
|
|
121
|
+
if (task.attempt < task.maxAttempts) {
|
|
122
|
+
transitionTask(task, 'READY');
|
|
123
|
+
task.leaseOwner = null;
|
|
124
|
+
task.leaseExpiresAt = null;
|
|
125
|
+
await this.store.saveTask(task);
|
|
126
|
+
}
|
|
127
|
+
else {
|
|
128
|
+
transitionTask(task, 'FAILED');
|
|
129
|
+
task.error = expired ? 'Lease expired after maximum attempts' : 'Execution owner restarted after maximum attempts';
|
|
130
|
+
task.leaseOwner = null;
|
|
131
|
+
task.leaseExpiresAt = null;
|
|
132
|
+
await this.store.saveTask(task);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
async maybeCreatePlanner(job) {
|
|
137
|
+
if (job.executionMode !== 'autonomous')
|
|
138
|
+
return job;
|
|
139
|
+
const planners = job.tasks.filter(task => task.kind === 'planner').sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
|
|
140
|
+
if (planners.some(task => ['QUEUED', 'READY', 'RUNNING', 'WAITING'].includes(task.state)))
|
|
141
|
+
return job;
|
|
142
|
+
if (job.tasks.some(task => task.state === 'FAILED' || task.state === 'BLOCKED'))
|
|
143
|
+
return job;
|
|
144
|
+
if (job.tasks.some(task => task.kind !== 'planner' && !['SUCCEEDED', 'CANCELLED'].includes(task.state)))
|
|
145
|
+
return job;
|
|
146
|
+
const latest = planners.at(-1);
|
|
147
|
+
if (latest?.plannerResult?.objective_status === 'satisfied')
|
|
148
|
+
return job;
|
|
149
|
+
const used = new Set(job.tasks.map(task => task.id));
|
|
150
|
+
let number = planners.length + 1;
|
|
151
|
+
let id = `planner-${number}`;
|
|
152
|
+
while (used.has(id)) {
|
|
153
|
+
number += 1;
|
|
154
|
+
id = `planner-${number}`;
|
|
155
|
+
}
|
|
156
|
+
const now = new Date().toISOString();
|
|
157
|
+
const task = { jobId: job.id, id, kind: 'planner', agentName: 'planner', parentTaskId: latest?.id ?? null, dependencies: [], capabilities: ['planner'], adapter: 'orchestrator', input: plannerInput({ job, tasks: job.tasks, objective: job.objective ?? job.description ?? job.title, previousPlannerTaskId: latest?.id ?? null }), continuation: null, continuationDeliveredAt: null, state: 'QUEUED', attempt: 0, maxAttempts: 3, leaseOwner: null, leaseExpiresAt: null, executionId: null, threadId: null, result: null, plannerResult: null, error: null, timeoutMs: this.options.plannerTimeoutMs ?? 300000, createdAt: now, updatedAt: now };
|
|
158
|
+
await this.store.appendTask(task);
|
|
159
|
+
await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'planner.created', 'queued', `Planner task ${task.id} created`, 'orchestrator'));
|
|
160
|
+
return this.store.load(job.id);
|
|
161
|
+
}
|
|
162
|
+
async recoverFinishedWorkers(job) {
|
|
163
|
+
for (const task of job.tasks.filter(item => item.state === 'RUNNING' && !this.live.has(item.id) && item.threadId)) {
|
|
164
|
+
const adapter = this.options.adapters.find(candidate => candidate.name === task.adapter);
|
|
165
|
+
if (!adapter?.recover)
|
|
166
|
+
continue;
|
|
167
|
+
let result = null;
|
|
168
|
+
try {
|
|
169
|
+
result = await adapter.recover(task);
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
result = null;
|
|
173
|
+
}
|
|
174
|
+
if (!result)
|
|
175
|
+
continue;
|
|
176
|
+
task.result = result;
|
|
177
|
+
task.error = null;
|
|
178
|
+
task.leaseOwner = null;
|
|
179
|
+
task.leaseExpiresAt = null;
|
|
180
|
+
transitionTask(task, 'SUCCEEDED');
|
|
181
|
+
await this.store.saveTask(task);
|
|
182
|
+
await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'task.recovered', 'succeeded', `Recovered completed task ${task.id} from durable worker thread`, 'orchestrator', { threadId: task.threadId }));
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
async launch(job, task) {
|
|
186
|
+
const adapter = this.options.adapters.find(x => x.name === task.adapter);
|
|
187
|
+
if (!adapter) {
|
|
188
|
+
transitionTask(task, 'FAILED');
|
|
189
|
+
task.error = `No adapter ${task.adapter}`;
|
|
190
|
+
await this.store.saveTask(task);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
const leaseMs = Math.max(this.options.leaseMs ?? 300000, task.timeoutMs);
|
|
194
|
+
const startedAt = (this.options.now ?? new Date()).getTime();
|
|
195
|
+
transitionTask(task, 'RUNNING');
|
|
196
|
+
task.attempt += 1;
|
|
197
|
+
task.leaseOwner = this.options.owner;
|
|
198
|
+
task.leaseExpiresAt = new Date(startedAt + leaseMs).toISOString();
|
|
199
|
+
task.executionId = randomUUID();
|
|
200
|
+
await this.store.saveTask(task);
|
|
201
|
+
await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'task.started', 'running', `Task ${task.id} started`, 'user', { adapter: adapter.id, model: task.routing?.model }));
|
|
202
|
+
const controller = new AbortController();
|
|
203
|
+
let execution;
|
|
204
|
+
const workerTask = { ...task, input: managedWorkerInput(job, task) };
|
|
205
|
+
try {
|
|
206
|
+
execution = adapter.launch(workerTask, controller.signal);
|
|
207
|
+
}
|
|
208
|
+
catch (error) {
|
|
209
|
+
await this.finish(job.id, task.id, task.executionId, null, error instanceof Error ? error.message : String(error));
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
// A worker may reject before durable execution metadata finishes saving. Attach a guard immediately so Node never treats that race as an unhandled rejection; the durable completion handler below still records the outcome.
|
|
213
|
+
void execution.promise.catch(() => undefined);
|
|
214
|
+
task.executionId = execution.id;
|
|
215
|
+
await this.store.saveTask(task);
|
|
216
|
+
let threadPersistence = Promise.resolve();
|
|
217
|
+
const persistThread = (threadId) => {
|
|
218
|
+
threadPersistence = threadPersistence.then(() => this.persistThread(job.id, task.id, execution.id, threadId));
|
|
219
|
+
};
|
|
220
|
+
execution.onThreadStarted = persistThread;
|
|
221
|
+
if (execution.threadId)
|
|
222
|
+
persistThread(execution.threadId);
|
|
223
|
+
const timer = setTimeout(() => { controller.abort(); execution.cancel(); }, task.timeoutMs);
|
|
224
|
+
this.live.set(task.id, { taskId: task.id, execution, controller, timer });
|
|
225
|
+
this.detach(execution.promise.then(async (result) => { await threadPersistence; await this.finish(job.id, task.id, execution.id, result, null); }, async (error) => { await threadPersistence; await this.finish(job.id, task.id, execution.id, null, error instanceof Error ? error.message : String(error)); }), job.id, task.id, 'worker completion');
|
|
226
|
+
}
|
|
227
|
+
async launchPlanner(job, task) {
|
|
228
|
+
const planner = this.options.planner;
|
|
229
|
+
if (!planner) {
|
|
230
|
+
task.attempt = task.maxAttempts;
|
|
231
|
+
task.error = 'No objective planner configured';
|
|
232
|
+
transitionTask(task, 'FAILED');
|
|
233
|
+
await this.store.saveTask(task);
|
|
234
|
+
await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'planner.failed', 'failed', `Planner ${task.id}: ${task.error}`, 'user'));
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
const startedAt = (this.options.now ?? new Date()).getTime();
|
|
238
|
+
transitionTask(task, 'RUNNING');
|
|
239
|
+
task.attempt += 1;
|
|
240
|
+
task.leaseOwner = this.options.owner;
|
|
241
|
+
task.leaseExpiresAt = new Date(startedAt + Math.max(this.options.leaseMs ?? 300000, task.timeoutMs)).toISOString();
|
|
242
|
+
task.executionId = randomUUID();
|
|
243
|
+
await this.store.saveTask(task);
|
|
244
|
+
await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'planner.started', 'running', `Planner task ${task.id} started`, 'orchestrator'));
|
|
245
|
+
const controller = new AbortController();
|
|
246
|
+
const executionId = task.executionId;
|
|
247
|
+
const timer = setTimeout(() => controller.abort(), task.timeoutMs);
|
|
248
|
+
this.plannerLive.set(task.id, { executionId: executionId, controller, timer });
|
|
249
|
+
let promise;
|
|
250
|
+
try {
|
|
251
|
+
promise = planner.plan({ job, tasks: job.tasks, objective: job.objective ?? job.description ?? job.title, previousPlannerTaskId: task.parentTaskId }, controller.signal);
|
|
252
|
+
}
|
|
253
|
+
catch (error) {
|
|
254
|
+
await this.finishPlanner(job.id, task.id, executionId, error instanceof Error ? error : new Error(String(error)));
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
this.detach(promise.then(result => this.finishPlanner(job.id, task.id, executionId, null, result), error => this.finishPlanner(job.id, task.id, executionId, error instanceof Error ? error : new Error(String(error)))), job.id, task.id, 'planner completion');
|
|
258
|
+
}
|
|
259
|
+
async finishPlanner(jobId, taskId, executionId, error, raw) {
|
|
260
|
+
const live = this.plannerLive.get(taskId);
|
|
261
|
+
if (live?.executionId === executionId) {
|
|
262
|
+
clearTimeout(live.timer);
|
|
263
|
+
this.plannerLive.delete(taskId);
|
|
264
|
+
}
|
|
265
|
+
const job = await this.store.load(jobId);
|
|
266
|
+
const task = job.tasks.find(item => item.id === taskId);
|
|
267
|
+
if (!task || (executionId !== null && task.executionId !== executionId))
|
|
268
|
+
return;
|
|
269
|
+
if (task.state === 'CANCELLED')
|
|
270
|
+
return;
|
|
271
|
+
if (error) {
|
|
272
|
+
task.error = error.message;
|
|
273
|
+
task.leaseOwner = null;
|
|
274
|
+
task.leaseExpiresAt = null;
|
|
275
|
+
transitionTask(task, task.attempt < task.maxAttempts ? 'READY' : 'FAILED');
|
|
276
|
+
await this.store.saveTask(task);
|
|
277
|
+
await this.emit(eventFor(job.id, task.id, task.parentTaskId, task.state === 'FAILED' ? 'planner.failed' : 'planner.retry', task.state === 'FAILED' ? 'failed' : 'queued', `Planner ${task.id}: ${error.message}`, 'user'));
|
|
278
|
+
this.detach(this.reconcile(jobId), jobId, task.id, 'planner retry reconciliation');
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
try {
|
|
282
|
+
const result = parsePlannerResult(raw);
|
|
283
|
+
for (const spec of result.next_tasks) {
|
|
284
|
+
const child = this.plannerChild(job, task, spec);
|
|
285
|
+
const existing = job.tasks.find(item => item.id === child.id);
|
|
286
|
+
if (!existing)
|
|
287
|
+
await this.store.appendTask(child);
|
|
288
|
+
else if (!samePlannerChildDefinition(existing, child))
|
|
289
|
+
throw new Error(`Planner task ${child.id} already exists with a different definition`);
|
|
290
|
+
}
|
|
291
|
+
task.plannerResult = result;
|
|
292
|
+
task.result = { summary: result.assessment, data: { objective_status: result.objective_status, next_tasks: result.next_tasks } };
|
|
293
|
+
task.error = null;
|
|
294
|
+
task.leaseOwner = null;
|
|
295
|
+
task.leaseExpiresAt = null;
|
|
296
|
+
transitionTask(task, 'SUCCEEDED');
|
|
297
|
+
await this.store.saveTask(task);
|
|
298
|
+
await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'planner.completed', 'succeeded', `Planner ${task.id} completed: ${result.objective_status}`, 'orchestrator', { objective_status: result.objective_status }));
|
|
299
|
+
}
|
|
300
|
+
catch (caught) {
|
|
301
|
+
const message = caught instanceof Error ? caught.message : String(caught);
|
|
302
|
+
task.error = message;
|
|
303
|
+
task.leaseOwner = null;
|
|
304
|
+
task.leaseExpiresAt = null;
|
|
305
|
+
transitionTask(task, task.attempt < task.maxAttempts ? 'READY' : 'FAILED');
|
|
306
|
+
await this.store.saveTask(task);
|
|
307
|
+
await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'planner.failed', 'failed', `Planner ${task.id}: ${message}`, 'user'));
|
|
308
|
+
}
|
|
309
|
+
this.detach(this.reconcile(jobId), jobId, task.id, 'planner follow-up reconciliation');
|
|
310
|
+
}
|
|
311
|
+
plannerChild(job, planner, spec) {
|
|
312
|
+
const now = new Date().toISOString();
|
|
313
|
+
return { jobId: job.id, id: spec.id, kind: 'work', priority: spec.priority ?? job.priority, projectName: spec.projectName, agentName: spec.agentName, parentTaskId: planner.id, dependencies: spec.dependencies ?? [], capabilities: spec.capabilities ?? [], adapter: spec.adapter ?? 'shell', input: spec.input, routing: spec.routing, continuation: spec.continuation ?? null, continuationDeliveredAt: null, state: 'QUEUED', attempt: 0, maxAttempts: spec.maxAttempts ?? 3, leaseOwner: null, leaseExpiresAt: null, executionId: null, threadId: null, result: null, plannerResult: null, error: null, timeoutMs: spec.timeoutMs ?? 300000, createdAt: now, updatedAt: now };
|
|
314
|
+
}
|
|
315
|
+
async persistThread(jobId, taskId, executionId, threadId) { const job = await this.store.load(jobId); const task = job.tasks.find(item => item.id === taskId); if (task?.executionId === executionId && task.state === 'RUNNING') {
|
|
316
|
+
task.threadId = threadId;
|
|
317
|
+
task.threadDeletedAt = null;
|
|
318
|
+
task.threadCleanupError = null;
|
|
319
|
+
await this.store.saveTask(task);
|
|
320
|
+
await this.emit(eventFor(jobId, taskId, task.parentTaskId, 'thread.started', 'running', `Worker thread ${threadId} started`, 'orchestrator', { threadId }));
|
|
321
|
+
} }
|
|
322
|
+
async finish(jobId, taskId, executionId, result, error) {
|
|
323
|
+
const live = this.live.get(taskId);
|
|
324
|
+
if (live?.execution.id === executionId) {
|
|
325
|
+
clearTimeout(live.timer);
|
|
326
|
+
this.live.delete(taskId);
|
|
327
|
+
}
|
|
328
|
+
const job = await this.store.load(jobId);
|
|
329
|
+
const task = job.tasks.find(item => item.id === taskId);
|
|
330
|
+
if (!task || task.executionId !== executionId)
|
|
331
|
+
return;
|
|
332
|
+
if (task.state === 'CANCELLED') {
|
|
333
|
+
await this.store.saveTask(task);
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
if (error === null) {
|
|
337
|
+
task.result = result;
|
|
338
|
+
task.error = null;
|
|
339
|
+
task.leaseOwner = null;
|
|
340
|
+
task.leaseExpiresAt = null;
|
|
341
|
+
transitionTask(task, 'SUCCEEDED');
|
|
342
|
+
await this.store.saveTask(task);
|
|
343
|
+
await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'task.completed', 'succeeded', `Task ${task.id} completed`));
|
|
344
|
+
await this.deliverContinuation(job, task);
|
|
345
|
+
}
|
|
346
|
+
else {
|
|
347
|
+
task.error = error;
|
|
348
|
+
task.leaseOwner = null;
|
|
349
|
+
task.leaseExpiresAt = null;
|
|
350
|
+
transitionTask(task, task.attempt < task.maxAttempts ? 'READY' : 'FAILED');
|
|
351
|
+
await this.store.saveTask(task);
|
|
352
|
+
await this.emit(eventFor(job.id, task.id, task.parentTaskId, task.state === 'FAILED' ? 'task.failed' : 'task.retry', task.state === 'FAILED' ? 'failed' : 'queued', `Task ${task.id}: ${error}`, task.state === 'FAILED' ? 'user' : 'orchestrator'));
|
|
353
|
+
}
|
|
354
|
+
this.detach(this.reconcile(jobId), jobId, task.id, 'worker follow-up reconciliation');
|
|
355
|
+
}
|
|
356
|
+
async cleanupThreads(job) {
|
|
357
|
+
const adapter = this.options.adapters.find(candidate => candidate.name === 'chatgpt' && candidate.deleteThread);
|
|
358
|
+
if (!adapter?.deleteThread)
|
|
359
|
+
return;
|
|
360
|
+
for (const task of job.tasks.filter(item => item.adapter === 'chatgpt' && item.threadId && !item.threadDeletedAt)) {
|
|
361
|
+
try {
|
|
362
|
+
await adapter.deleteThread(task.threadId);
|
|
363
|
+
task.threadDeletedAt = new Date().toISOString();
|
|
364
|
+
task.threadCleanupError = null;
|
|
365
|
+
await this.store.saveTask(task);
|
|
366
|
+
await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'thread.deleted', 'succeeded', `Worker thread ${task.threadId} deleted`, 'orchestrator', { threadId: task.threadId }));
|
|
367
|
+
}
|
|
368
|
+
catch (error) {
|
|
369
|
+
task.threadCleanupError = error instanceof Error ? error.message : String(error);
|
|
370
|
+
await this.store.saveTask(task);
|
|
371
|
+
await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'thread.delete.failed', 'failed', `Worker thread cleanup failed: ${task.threadCleanupError}`, 'orchestrator', { threadId: task.threadId }));
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
async deliverContinuation(job, task) {
|
|
376
|
+
const continuation = task.continuation ?? job.continuation;
|
|
377
|
+
if (!continuation || task.continuationDeliveredAt || this.continuationLive.has(task.id))
|
|
378
|
+
return;
|
|
379
|
+
const adapter = this.options.continuations?.find(candidate => candidate.kind === continuation.kind);
|
|
380
|
+
if (!adapter) {
|
|
381
|
+
task.error = `No continuation adapter for ${continuation.kind}`;
|
|
382
|
+
await this.store.saveTask(task);
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
this.continuationLive.add(task.id);
|
|
386
|
+
try {
|
|
387
|
+
await adapter.continue(task.result?.summary ?? '', continuation);
|
|
388
|
+
const fresh = await this.store.load(job.id);
|
|
389
|
+
const durable = fresh.tasks.find(item => item.id === task.id);
|
|
390
|
+
if (durable?.state === 'SUCCEEDED' && !durable.continuationDeliveredAt) {
|
|
391
|
+
durable.continuationDeliveredAt = new Date().toISOString();
|
|
392
|
+
await this.store.saveTask(durable);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
catch (error) {
|
|
396
|
+
await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'continuation.failed', 'failed', `Continuation failed: ${error instanceof Error ? error.message : String(error)}`, 'user'));
|
|
397
|
+
}
|
|
398
|
+
finally {
|
|
399
|
+
this.continuationLive.delete(task.id);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
async emit(event) { try {
|
|
403
|
+
if (this.options.emit)
|
|
404
|
+
await this.options.emit(event);
|
|
405
|
+
}
|
|
406
|
+
catch { /* observability cannot block durable execution */ } try {
|
|
407
|
+
if (this.options.eventBus)
|
|
408
|
+
await this.options.eventBus.publish(event);
|
|
409
|
+
}
|
|
410
|
+
catch (error) {
|
|
411
|
+
await this.reportTransportFailure(error);
|
|
412
|
+
} }
|
|
413
|
+
detach(promise, jobId, taskId, context) {
|
|
414
|
+
void promise.catch(error => this.reportDetachedFailure(jobId, taskId, context, error));
|
|
415
|
+
}
|
|
416
|
+
async reportDetachedFailure(jobId, taskId, context, error) {
|
|
417
|
+
if (!this.options.emit)
|
|
418
|
+
return;
|
|
419
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
420
|
+
try {
|
|
421
|
+
await this.options.emit(eventFor(jobId, taskId, null, 'runtime.persistence.failed', 'failed', `${context} failed: ${message}`, 'orchestrator'));
|
|
422
|
+
}
|
|
423
|
+
catch { /* detached failure reporting must never crash the daemon */ }
|
|
424
|
+
}
|
|
425
|
+
async reportTransportFailure(error) { if (this.options.emit) {
|
|
426
|
+
try {
|
|
427
|
+
await this.options.emit(eventFor('system', 'event-bus', null, 'event.transport.failed', 'failed', `Event transport unavailable: ${error instanceof Error ? error.message : String(error)}`, 'orchestrator'));
|
|
428
|
+
}
|
|
429
|
+
catch { /* reporting is best effort */ }
|
|
430
|
+
} }
|
|
431
|
+
}
|
|
432
|
+
function samePlannerChildDefinition(left, right) {
|
|
433
|
+
return JSON.stringify({ id: left.id, input: left.input, priority: left.priority, projectName: left.projectName, agentName: left.agentName, dependencies: left.dependencies, capabilities: left.capabilities, adapter: left.adapter, routing: left.routing, continuation: left.continuation ?? null, maxAttempts: left.maxAttempts, timeoutMs: left.timeoutMs }) === JSON.stringify({ id: right.id, input: right.input, priority: right.priority, projectName: right.projectName, agentName: right.agentName, dependencies: right.dependencies, capabilities: right.capabilities, adapter: right.adapter, routing: right.routing, continuation: right.continuation ?? null, maxAttempts: right.maxAttempts, timeoutMs: right.timeoutMs });
|
|
434
|
+
}
|
package/dist/registry.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
function hasAll(values, required) { return required.every(value => values.includes(value)); }
|
|
2
|
+
function matches(agent, query) {
|
|
3
|
+
if (query.capabilities && !hasAll(agent.capabilities, query.capabilities))
|
|
4
|
+
return false;
|
|
5
|
+
if (query.runtime && agent.runtime.adapter !== query.runtime)
|
|
6
|
+
return false;
|
|
7
|
+
if (query.availability && !query.availability.includes(agent.availability))
|
|
8
|
+
return false;
|
|
9
|
+
if (query.trustDomain && !agent.routing.trustDomains?.includes(query.trustDomain))
|
|
10
|
+
return false;
|
|
11
|
+
return !query.labels || Object.entries(query.labels).every(([key, value]) => agent.routing.labels?.[key] === value);
|
|
12
|
+
}
|
|
13
|
+
function reliability(agent) { const { succeeded, failed, timedOut } = agent.evidence.outcomes; const total = succeeded + failed + timedOut; return total === 0 ? 0 : succeeded / total; }
|
|
14
|
+
export function discoverAgents(agents, query = {}) {
|
|
15
|
+
const candidates = agents.filter(agent => matches(agent, query)).map(agent => {
|
|
16
|
+
const evaluated = query.taskKind ? agent.evidence.evaluations.some(item => item.taskKind === query.taskKind) : agent.evidence.evaluations.length > 0;
|
|
17
|
+
const reasons = [query.capabilities?.length ? `capabilities: ${query.capabilities.join(', ')}` : 'capabilities matched', evaluated ? 'observed evidence available' : 'no observed evidence yet'];
|
|
18
|
+
return { agent, reasons, evidence: agent.evidence, exploratory: !evaluated };
|
|
19
|
+
});
|
|
20
|
+
return candidates.sort((left, right) => {
|
|
21
|
+
if (query.explore && left.exploratory !== right.exploratory)
|
|
22
|
+
return left.exploratory ? -1 : 1;
|
|
23
|
+
if (left.agent.availability !== right.agent.availability)
|
|
24
|
+
return left.agent.availability === 'available' ? -1 : 1;
|
|
25
|
+
return reliability(right.agent) - reliability(left.agent);
|
|
26
|
+
});
|
|
27
|
+
}
|
package/dist/relayd.js
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { once } from 'node:events';
|
|
3
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { ChatGptAdapter, CodexAdapter, ShellAdapter } from './adapters.js';
|
|
7
|
+
import { CodexThreadContinuation, CommandContinuation, WebhookContinuation } from './continuation.js';
|
|
8
|
+
import { serveDashboard } from './dashboard.js';
|
|
9
|
+
import { NatsEventBus } from './events.js';
|
|
10
|
+
import { RepositoryWorkerPool, aggregateManagedGitHubJobs } from './pool.js';
|
|
11
|
+
import { Reconciler } from './reconciler.js';
|
|
12
|
+
import { dashboardManagedJobs, GitHubStore, InMemoryStore, loadManagedGitHubPullRequestGraphql } from './store.js';
|
|
13
|
+
import { githubAuthContext } from './github-auth.js';
|
|
14
|
+
import { isEntrypoint, startService, SERVICE_DEFAULTS } from './cli.js';
|
|
15
|
+
import { codexAndZaiUsageRegistry } from './usage.js';
|
|
16
|
+
import { defaultWorkspaceRoot, discoverWorkspaceRepositories } from './workspace.js';
|
|
17
|
+
export const daemonHelp = `Agents Relay daemon runs the dashboard and worker pool.
|
|
18
|
+
|
|
19
|
+
Usage:
|
|
20
|
+
npx agents-relayd [options]
|
|
21
|
+
|
|
22
|
+
Options:
|
|
23
|
+
--repo OWNER/REPO Watch one repository
|
|
24
|
+
--workspace PATH Discover repositories below PATH (default: ~/Workspace)
|
|
25
|
+
--port PORT Dashboard port (default: 8787)
|
|
26
|
+
--concurrency N Maximum concurrent workers (default: 4)
|
|
27
|
+
--interval MS Recovery interval
|
|
28
|
+
--events nats Enable event wakeups
|
|
29
|
+
|
|
30
|
+
Legacy single-job mode also accepts --repo OWNER/REPO --pr NUMBER --id JOB_ID.
|
|
31
|
+
|
|
32
|
+
Examples:
|
|
33
|
+
npx agents-relayd --repo OWNER/REPO
|
|
34
|
+
npx agents-relayd --workspace ~/Workspace`;
|
|
35
|
+
function value(args, name, fallback = '') { const index = args.indexOf(name); return index >= 0 ? args[index + 1] ?? fallback : fallback; }
|
|
36
|
+
export function normalizeDaemonArguments(argv) {
|
|
37
|
+
const repository = value(argv, '--repo') || null;
|
|
38
|
+
const workspaceRoot = value(argv, '--workspace', defaultWorkspaceRoot()) || null;
|
|
39
|
+
const prValue = value(argv, '--pr');
|
|
40
|
+
const id = value(argv, '--id');
|
|
41
|
+
if ((prValue && !id) || (!prValue && id))
|
|
42
|
+
throw new Error('--pr and --id must be supplied together for legacy single-job mode');
|
|
43
|
+
if ((prValue || id) && !repository)
|
|
44
|
+
throw new Error('--repo is required for legacy single-job mode');
|
|
45
|
+
if (repository && argv.includes('--workspace'))
|
|
46
|
+
throw new Error('--repo and --workspace are mutually exclusive');
|
|
47
|
+
const pullRequest = prValue ? Number(prValue) : null;
|
|
48
|
+
if (pullRequest !== null && (!Number.isInteger(pullRequest) || pullRequest <= 0))
|
|
49
|
+
throw new Error('--pr must be positive');
|
|
50
|
+
return { repository, workspaceRoot: repository ? null : workspaceRoot, pullRequest, jobId: id || null, serviceArguments: [...argv] };
|
|
51
|
+
}
|
|
52
|
+
export async function runDaemon(argv) {
|
|
53
|
+
if (argv.includes('--help') || argv.includes('-h')) {
|
|
54
|
+
console.log(daemonHelp);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const normalized = normalizeDaemonArguments(argv);
|
|
58
|
+
if (normalized.pullRequest && normalized.jobId) {
|
|
59
|
+
await startService([...argv]);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const auth = await githubAuthContext(argv);
|
|
63
|
+
const client = auth.client;
|
|
64
|
+
const trusted = auth.trustedAuthors;
|
|
65
|
+
const concurrency = Number(value(argv, '--concurrency', '4'));
|
|
66
|
+
const bus = value(argv, '--events') === 'nats' ? new NatsEventBus(value(argv, '--nats-url', 'nats://127.0.0.1:4222'), value(argv, '--subject-prefix', 'agents-relay.events.job')) : undefined;
|
|
67
|
+
const makeReconciler = (store, maxConcurrent) => new Reconciler(store, { owner: `relayd-${process.pid}`, maxConcurrent, leaseMs: Number(value(argv, '--lease-ms', '300000')), adapters: [new ShellAdapter(), new CodexAdapter(value(argv, '--codex', 'codex')), new ChatGptAdapter({ endpoint: value(argv, '--macbridge-url') || undefined, tokenFile: value(argv, '--macbridge-token-file') || undefined })], continuations: [new CodexThreadContinuation(value(argv, '--codex', 'codex')), new CommandContinuation(), new WebhookContinuation()], eventBus: bus });
|
|
68
|
+
const repositories = normalized.repository
|
|
69
|
+
? [normalized.repository]
|
|
70
|
+
: (await discoverWorkspaceRepositories(normalized.workspaceRoot ?? defaultWorkspaceRoot())).map(item => item.repository);
|
|
71
|
+
if (repositories.length === 0)
|
|
72
|
+
throw new Error(`No Git repositories with GitHub origins found under ${normalized.workspaceRoot ?? defaultWorkspaceRoot()}`);
|
|
73
|
+
const pool = new RepositoryWorkerPool(client, repositories, trusted, concurrency, makeReconciler);
|
|
74
|
+
const cacheDirectory = join(homedir(), '.agents-relay');
|
|
75
|
+
const cacheFile = join(cacheDirectory, 'dashboard-jobs.json');
|
|
76
|
+
let cachedOverviews = [];
|
|
77
|
+
try {
|
|
78
|
+
const parsed = JSON.parse(await readFile(cacheFile, 'utf8'));
|
|
79
|
+
if (Array.isArray(parsed))
|
|
80
|
+
cachedOverviews = parsed;
|
|
81
|
+
}
|
|
82
|
+
catch { /* first run or invalid cache */ }
|
|
83
|
+
const refreshAll = async () => {
|
|
84
|
+
if (client.rateLimitStatus().limited)
|
|
85
|
+
return cachedOverviews;
|
|
86
|
+
try {
|
|
87
|
+
cachedOverviews = dashboardManagedJobs(await aggregateManagedGitHubJobs(client, repositories, trusted));
|
|
88
|
+
try {
|
|
89
|
+
await mkdir(cacheDirectory, { recursive: true });
|
|
90
|
+
await writeFile(cacheFile, JSON.stringify(cachedOverviews), 'utf8');
|
|
91
|
+
}
|
|
92
|
+
catch { /* cache is best effort */ }
|
|
93
|
+
return cachedOverviews;
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
process.stderr.write(`warning: workspace job refresh unavailable: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
97
|
+
return cachedOverviews;
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
const overviews = async (forceRefresh = false) => forceRefresh ? refreshAll() : cachedOverviews;
|
|
101
|
+
const persistCache = async () => {
|
|
102
|
+
try {
|
|
103
|
+
await mkdir(cacheDirectory, { recursive: true });
|
|
104
|
+
await writeFile(cacheFile, JSON.stringify(cachedOverviews), 'utf8');
|
|
105
|
+
}
|
|
106
|
+
catch { /* cache is best effort */ }
|
|
107
|
+
};
|
|
108
|
+
const refreshPullRequest = async (repository, prNumber) => {
|
|
109
|
+
const refreshed = await loadManagedGitHubPullRequestGraphql(client, repository, prNumber, trusted);
|
|
110
|
+
cachedOverviews = cachedOverviews.filter(item => item.job.repository !== repository || item.job.prNumber !== prNumber);
|
|
111
|
+
cachedOverviews.push(...refreshed);
|
|
112
|
+
cachedOverviews = dashboardManagedJobs(cachedOverviews);
|
|
113
|
+
await persistCache();
|
|
114
|
+
return refreshed;
|
|
115
|
+
};
|
|
116
|
+
const initial = await refreshAll();
|
|
117
|
+
const fallback = initial[0]?.job;
|
|
118
|
+
const dashboardStore = fallback ? new GitHubStore(client, fallback.repository, fallback.prNumber, trusted) : new InMemoryStore();
|
|
119
|
+
const secretFile = value(argv, '--github-webhook-secret-file');
|
|
120
|
+
const secret = value(argv, '--github-webhook-secret') || process.env.AGENTS_RELAY_GITHUB_WEBHOOK_SECRET || (secretFile ? (await readFile(secretFile, 'utf8')).trim() : '');
|
|
121
|
+
const webhook = secret ? {
|
|
122
|
+
secret,
|
|
123
|
+
repositories: new Set(repositories),
|
|
124
|
+
wake: async (event) => {
|
|
125
|
+
if (!event.repository || !event.pullRequest)
|
|
126
|
+
return;
|
|
127
|
+
try {
|
|
128
|
+
const refreshed = await refreshPullRequest(event.repository, event.pullRequest);
|
|
129
|
+
for (const item of refreshed)
|
|
130
|
+
await pool.webhookWake(bus, item.job, event.deliveryId, event.event, event.action);
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
process.stderr.write(`warning: webhook PR refresh unavailable: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
} : undefined;
|
|
137
|
+
const exactJob = async (jobId) => {
|
|
138
|
+
const cached = cachedOverviews.find(item => item.job.id === jobId);
|
|
139
|
+
if (!cached)
|
|
140
|
+
return null;
|
|
141
|
+
try {
|
|
142
|
+
const refreshed = (await refreshPullRequest(cached.job.repository, cached.job.prNumber)).find(item => item.job.id === jobId);
|
|
143
|
+
if (!refreshed)
|
|
144
|
+
return null;
|
|
145
|
+
return { ...refreshed.job, githubState: refreshed.githubState, mergedAt: refreshed.mergedAt, closedAt: refreshed.closedAt, draft: refreshed.draft };
|
|
146
|
+
}
|
|
147
|
+
catch (error) {
|
|
148
|
+
process.stderr.write(`warning: selected job refresh unavailable: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
const usageRegistry = codexAndZaiUsageRegistry();
|
|
153
|
+
const server = serveDashboard(dashboardStore, Number(value(argv, '--port', String(SERVICE_DEFAULTS.port))), bus, Number(value(argv, '--refresh-ms', String(SERVICE_DEFAULTS.dashboardRefreshMs))), fallback?.id, usageRegistry, overviews, webhook, () => client.rateLimitStatus(), exactJob);
|
|
154
|
+
await once(server, 'listening');
|
|
155
|
+
const reconcileSafely = async () => {
|
|
156
|
+
if (client.rateLimitStatus().limited)
|
|
157
|
+
return;
|
|
158
|
+
try {
|
|
159
|
+
await pool.reconcile();
|
|
160
|
+
}
|
|
161
|
+
catch (error) {
|
|
162
|
+
process.stderr.write(`warning: workspace reconcile unavailable: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
const watchdogDefault = webhook ? SERVICE_DEFAULTS.webhookWatchdogMs : SERVICE_DEFAULTS.watchdogMs;
|
|
166
|
+
const interval = setInterval(() => void reconcileSafely(), Number(value(argv, '--interval', String(watchdogDefault))));
|
|
167
|
+
interval.unref();
|
|
168
|
+
const stopWatch = bus ? await pool.watch(bus) : async () => { };
|
|
169
|
+
const stop = () => { clearInterval(interval); void stopWatch().finally(() => server.close(() => process.exit(0))); };
|
|
170
|
+
process.once('SIGINT', stop);
|
|
171
|
+
process.once('SIGTERM', stop);
|
|
172
|
+
console.log('Dashboard listening; repository worker pool active');
|
|
173
|
+
if (!client.rateLimitStatus().limited)
|
|
174
|
+
void pool.reconcileKnown(initial).catch(error => process.stderr.write(`warning: startup reconciliation unavailable: ${error instanceof Error ? error.message : String(error)}\n`));
|
|
175
|
+
}
|
|
176
|
+
if (isEntrypoint(import.meta.url))
|
|
177
|
+
runDaemon(process.argv.slice(2)).catch(error => { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; });
|