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
package/dist/relayd.js
CHANGED
|
@@ -6,15 +6,17 @@ import { join } from 'node:path';
|
|
|
6
6
|
import { ChatGptAdapter, CodexAdapter, ensureChatGptBrowserWorkspace } from './adapters.js';
|
|
7
7
|
import { CodexThreadContinuation, CommandContinuation, WebhookContinuation } from './continuation.js';
|
|
8
8
|
import { serveDashboard } from './dashboard.js';
|
|
9
|
-
import { RepositoryWorkerPool
|
|
9
|
+
import { RepositoryWorkerPool } from './pool.js';
|
|
10
10
|
import { Reconciler } from './reconciler.js';
|
|
11
|
-
import { dashboardManagedJobs, GitHubStore, InMemoryStore
|
|
12
|
-
import {
|
|
11
|
+
import { dashboardManagedJobs, GitHubStore, InMemoryStore } from './store.js';
|
|
12
|
+
import { LocalAuthorityDurableStore } from './local-authority-store.js';
|
|
13
13
|
import { discoverLocalRelayStoreSnapshots, relayStorePath, SQLiteRelayStore } from './relay-store.js';
|
|
14
|
-
import {
|
|
14
|
+
import { createJobIdRegistryResolver } from './local-job-registry.js';
|
|
15
|
+
import { GhApiClient } from './github-client.js';
|
|
16
|
+
import { DisabledGitHubClient, noGitHubMode } from './no-github.js';
|
|
17
|
+
import { basename } from 'node:path';
|
|
15
18
|
import { dashboardJobAction, isEntrypoint, runtimePlanner, runtimeReviewer, startService, SERVICE_DEFAULTS } from './cli.js';
|
|
16
19
|
import { eventBusResolutionMessage, publishOrchestratorTaskStarted, publishOrchestratorTaskCompletion, resolveEventBus } from './cli-support.js';
|
|
17
|
-
import { eventFor } from './events.js';
|
|
18
20
|
import { applyJobActivateAction } from './job-actions.js';
|
|
19
21
|
import { applyOrchestratorStartAction, applyOrchestratorCompleteAction } from './task-actions.js';
|
|
20
22
|
import { managedTaskUpdateHandler } from './task-update.js';
|
|
@@ -56,7 +58,7 @@ export function mergeOperationalInventory(current, discovered) {
|
|
|
56
58
|
const PR_REFRESH_COALESCE_MS = 1000;
|
|
57
59
|
export const SCHEDULE_HEARTBEAT_MS = 600_000;
|
|
58
60
|
export function dueScheduledOverviews(overviews, now = new Date()) {
|
|
59
|
-
return overviews.filter(item => item.
|
|
61
|
+
return overviews.filter(item => item.job.state === 'ACTIVE' && item.job.executionMode === 'autonomous' && item.job.schedule && Date.parse(item.job.schedule.nextRunAt) <= now.getTime());
|
|
60
62
|
}
|
|
61
63
|
export function shouldCoalescePullRequestRefresh(completedAt, now = Date.now(), force = false) {
|
|
62
64
|
return !force && completedAt !== null && now >= completedAt && now - completedAt < PR_REFRESH_COALESCE_MS;
|
|
@@ -67,6 +69,7 @@ Usage:
|
|
|
67
69
|
npx agents-relayd [options]
|
|
68
70
|
|
|
69
71
|
Options:
|
|
72
|
+
--no-github Local-only mode: no GitHub auth, transport, PR, merge, or fetch
|
|
70
73
|
--repo OWNER/REPO Watch one repository
|
|
71
74
|
--workspace PATH Discover repositories below PATH (default: ~/Workspace)
|
|
72
75
|
--port PORT Dashboard port (default: 8787)
|
|
@@ -117,7 +120,7 @@ export function recoveryScanRepositories(repositories, cached, dormantProbeIndex
|
|
|
117
120
|
const dormant = [];
|
|
118
121
|
for (const repository of repositories) {
|
|
119
122
|
const values = byRepository.get(repository);
|
|
120
|
-
if (!values || values.length === 0 || values.some(item =>
|
|
123
|
+
if (!values || values.length === 0 || values.some(item => !['COMPLETED', 'CANCELLED'].includes(item.job.state)))
|
|
121
124
|
activeOrUnknown.push(repository);
|
|
122
125
|
else
|
|
123
126
|
dormant.push(repository);
|
|
@@ -145,22 +148,30 @@ export async function runDaemon(argv) {
|
|
|
145
148
|
catch (error) {
|
|
146
149
|
process.stderr.write(`Browser Workspace bootstrap skipped: ${errorMessage(error)}\n`);
|
|
147
150
|
}
|
|
151
|
+
const noGitHub = noGitHubMode(argv);
|
|
152
|
+
if (noGitHub)
|
|
153
|
+
process.env.AGENTS_RELAY_NO_GITHUB = '1';
|
|
148
154
|
const normalized = normalizeDaemonArguments(argv);
|
|
149
155
|
if (normalized.pullRequest && normalized.jobId) {
|
|
150
156
|
await startService([...argv]);
|
|
151
157
|
return;
|
|
152
158
|
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
const
|
|
159
|
+
// Local authority starts without GitHub authentication. External audit transport
|
|
160
|
+
// is optional and may initialize independently after the daemon is online.
|
|
161
|
+
const client = noGitHub ? new DisabledGitHubClient() : new GhApiClient();
|
|
162
|
+
const trusted = new Set();
|
|
156
163
|
const concurrency = Number(value(argv, '--concurrency', '4'));
|
|
157
164
|
const eventResolution = await resolveEventBus([...argv]);
|
|
158
165
|
const bus = eventResolution.bus;
|
|
159
166
|
process.stderr.write(`${eventBusResolutionMessage(eventResolution)}\n`);
|
|
160
|
-
const workspaceRoot =
|
|
161
|
-
|
|
162
|
-
const
|
|
167
|
+
const workspaceRoot = noGitHub ? (value(argv, '--project-path') || value(argv, '--workspace') || process.cwd())
|
|
168
|
+
: normalized.workspaceRoot ?? defaultWorkspaceRoot();
|
|
169
|
+
const discoveredRepositories = normalized.repository || noGitHub ? [] : await discoverWorkspaceRepositories(workspaceRoot).catch(() => []);
|
|
170
|
+
const repositories = noGitHub && !normalized.repository ? [`local/${basename(workspaceRoot)}`] : normalized.repository ? [normalized.repository] : discoveredRepositories.map(item => item.repository);
|
|
163
171
|
const repositoryPaths = new Map(discoveredRepositories.map(item => [item.repository, item.path]));
|
|
172
|
+
if (noGitHub)
|
|
173
|
+
for (const repo of repositories)
|
|
174
|
+
repositoryPaths.set(repo, value(argv, '--project-path') || workspaceRoot);
|
|
164
175
|
const explicitProjectPath = value(argv, '--project-path');
|
|
165
176
|
const usageRegistry = codexAndZaiUsageRegistry();
|
|
166
177
|
const decisionAuditStore = new JsonlDecisionAuditStore(process.env.AGENTS_RELAY_DECISION_AUDIT_PATH ?? join(homedir(), '.agents-relay', 'decision-audit.jsonl'));
|
|
@@ -177,97 +188,62 @@ export async function runDaemon(argv) {
|
|
|
177
188
|
relayStores.set(key, relay);
|
|
178
189
|
}
|
|
179
190
|
const github = new GitHubStore(client, repository, prNumber, trusted, seed);
|
|
180
|
-
const projected = new
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
void Promise.resolve().then(() => bus.publish(eventFor(jobId, 'projection', null, 'projection.conflict', 'blocked', conflict.message, 'orchestrator', { ...conflict.data, repository, prNumber }))).catch(() => undefined);
|
|
185
|
-
}
|
|
186
|
-
});
|
|
187
|
-
projectionWriters.set(key, projected.projection);
|
|
191
|
+
const projected = new LocalAuthorityDurableStore(relay, github, (relatedRepository, relatedPrNumber) => runtimeStore(relatedRepository, relatedPrNumber), noGitHub);
|
|
192
|
+
// SQLite PR facts are written through awaited webhook/action handlers; never seed detached remote snapshots.
|
|
193
|
+
if (!noGitHub)
|
|
194
|
+
projectionWriters.set(key, projected.projection);
|
|
188
195
|
return projected;
|
|
189
196
|
};
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
return [];
|
|
197
|
-
return snapshot.jobs.filter(job => knownRepositories.has(job.repository)).map(job => ({
|
|
198
|
-
job, githubState: snapshot.facts.state, draft: false, mergedAt: null, closedAt: null, publication: null,
|
|
199
|
-
}));
|
|
200
|
-
}));
|
|
201
|
-
const makeReconciler = (store, maxConcurrent) => new Reconciler(store, { owner: `relayd-${process.pid}`, maxConcurrent, leaseMs: Number(value(argv, '--lease-ms', '300000')), adapters: [new CodexAdapter(value(argv, '--codex', 'codex')), new ChatGptAdapter()], continuations: [new CodexThreadContinuation(value(argv, '--codex', 'codex')), new CommandContinuation(), new WebhookContinuation()], planner: runtimePlanner(argv), usageSnapshotProvider: () => usageRegistry.snapshot(), runtimeSummaryProvider: () => ({ chatgptRunning: summarizeRuntime(cachedOverviews.map(item => item.job)).workers.chatgpt }), decisionEngine: executionDecisionEngine(), decisionMode: decisionMode(), decisionAuditStore, eventBus: bus, reviewer: runtimeReviewer(argv), projectPathForJob: async (job) => resolveRepositoryProjectPath(repositoryPaths.get(job.repository) ?? await resolveManagedProjectPath(job.repository, explicitProjectPath, workspaceRoot), job.projectPath) });
|
|
202
|
-
const refreshAll = async (full = false) => {
|
|
203
|
-
if (client.rateLimitStatus().limited)
|
|
204
|
-
return cachedOverviews;
|
|
205
|
-
const scanRepositories = full ? repositories : recoveryScanRepositories(repositories, cachedOverviews, dormantProbeIndex++);
|
|
206
|
-
if (scanRepositories.length === 0)
|
|
207
|
-
return cachedOverviews;
|
|
208
|
-
try {
|
|
209
|
-
githubReadPaths.workspaceScans += 1;
|
|
210
|
-
const refreshed = await withGitHubRequestContext(client, 'relayd.workspace-refresh', () => aggregateManagedGitHubJobs(client, scanRepositories, trusted));
|
|
211
|
-
const merged = mergeOperationalInventory(cachedOverviews, refreshed);
|
|
212
|
-
cachedOverviews = dashboardManagedJobs(await overlayOperationalOverviews(merged, (repository, prNumber) => runtimeStore(repository, prNumber)));
|
|
213
|
-
return cachedOverviews;
|
|
214
|
-
}
|
|
215
|
-
catch (error) {
|
|
216
|
-
observability().record({ stage: 'daemon', event: 'daemon.refresh_failed', level: 'warn', outcome: 'failed', message: `Workspace job refresh unavailable: ${errorMessage(error)}`, data: { classification: classifyFailure(error).classification, reason: errorMessage(error) } });
|
|
217
|
-
return cachedOverviews;
|
|
197
|
+
// SQLite inventory, not GitHub discovery, defines which repositories exist.
|
|
198
|
+
const existingInventory = await discoverLocalRelayStoreSnapshots(join(homedir(), '.agents-relay', 'stores'));
|
|
199
|
+
for (const snapshot of existingInventory)
|
|
200
|
+
for (const job of snapshot.jobs) {
|
|
201
|
+
if (!repositories.includes(job.repository))
|
|
202
|
+
repositories.push(job.repository);
|
|
218
203
|
}
|
|
204
|
+
if (repositories.length === 0)
|
|
205
|
+
repositories.push('local/' + basename(workspaceRoot));
|
|
206
|
+
const inventoryFromLocal = async () => {
|
|
207
|
+
const snapshots = await discoverLocalRelayStoreSnapshots(join(homedir(), '.agents-relay', 'stores'));
|
|
208
|
+
const known = new Set(repositories);
|
|
209
|
+
const entries = snapshots.flatMap(snapshot => snapshot.jobs.filter(job => known.has(job.repository)).map(job => ({
|
|
210
|
+
job, githubState: snapshot.facts?.state ?? 'UNKNOWN',
|
|
211
|
+
draft: false, mergedAt: null, closedAt: null, publication: snapshot.publications[job.id]?.latest ?? null, publicationFailure: snapshot.publications[job.id]?.failure ?? null,
|
|
212
|
+
})));
|
|
213
|
+
const counts = new Map();
|
|
214
|
+
for (const item of entries)
|
|
215
|
+
if (!['COMPLETED', 'CANCELLED'].includes(item.job.state))
|
|
216
|
+
counts.set(item.job.id, (counts.get(item.job.id) ?? 0) + 1);
|
|
217
|
+
// Hide obsolete terminal snapshots only when one live authoritative Job exists.
|
|
218
|
+
// Multiple live copies remain visible for diagnosis but cannot be mutated.
|
|
219
|
+
return dashboardManagedJobs(entries.filter(item => !['COMPLETED', 'CANCELLED'].includes(item.job.state) || counts.get(item.job.id) !== 1));
|
|
219
220
|
};
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
cachedOverviews = await
|
|
221
|
+
cachedOverviews = await inventoryFromLocal();
|
|
222
|
+
const makeReconciler = (store, maxConcurrent) => new Reconciler(store, { noGitHub, owner: `relayd-${process.pid}`, maxConcurrent, leaseMs: Number(value(argv, '--lease-ms', '300000')), adapters: [new CodexAdapter(value(argv, '--codex', 'codex')), new ChatGptAdapter()], continuations: [new CodexThreadContinuation(value(argv, '--codex', 'codex')), new CommandContinuation(), new WebhookContinuation()], planner: runtimePlanner(argv), usageSnapshotProvider: () => usageRegistry.snapshot(), runtimeSummaryProvider: () => ({ chatgptRunning: summarizeRuntime(cachedOverviews.map(item => item.job)).workers.chatgpt }), decisionEngine: executionDecisionEngine(), decisionMode: decisionMode(), decisionAuditStore, eventBus: bus, reviewer: runtimeReviewer(argv), projectPathForJob: async (job) => resolveRepositoryProjectPath(repositoryPaths.get(job.repository) ?? await resolveManagedProjectPath(job.repository, explicitProjectPath, workspaceRoot), job.projectPath) });
|
|
223
|
+
const refreshAll = async (_full = false) => {
|
|
224
|
+
cachedOverviews = await inventoryFromLocal();
|
|
224
225
|
return cachedOverviews;
|
|
225
226
|
};
|
|
226
|
-
const
|
|
227
|
-
const
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
incrementTrigger(githubReadPaths.prRefreshesByTrigger, trigger);
|
|
234
|
-
const existing = pullRequestRefreshFlights.get(key);
|
|
235
|
-
if (existing) {
|
|
236
|
-
githubReadPaths.prRefreshCoalesced += 1;
|
|
237
|
-
incrementTrigger(githubReadPaths.prRefreshCoalescedByTrigger, trigger);
|
|
238
|
-
return existing;
|
|
239
|
-
}
|
|
240
|
-
const cached = pullRequestRefreshCache.get(key);
|
|
241
|
-
if (cached && shouldCoalescePullRequestRefresh(cached.completedAt, Date.now(), options.force === true)) {
|
|
242
|
-
githubReadPaths.prRefreshCoalesced += 1;
|
|
243
|
-
incrementTrigger(githubReadPaths.prRefreshCoalescedByTrigger, trigger);
|
|
244
|
-
return cached.snapshot;
|
|
245
|
-
}
|
|
246
|
-
const flight = (async () => {
|
|
247
|
-
githubReadPaths.prRefreshes += 1;
|
|
248
|
-
const snapshot = await withGitHubRequestContext(client, `relayd.pr-refresh:${trigger}`, () => loadManagedGitHubPullRequestSnapshotGraphql(client, repository, prNumber, trusted));
|
|
249
|
-
const operational = await overlayOperationalOverviews(snapshot.overviews, (repo, pr) => runtimeStore(repo, pr, repo === repository && pr === prNumber ? snapshot.seed : null));
|
|
250
|
-
cachedOverviews = cachedOverviews.filter(item => item.job.repository !== repository || item.job.prNumber !== prNumber);
|
|
251
|
-
cachedOverviews.push(...operational);
|
|
252
|
-
cachedOverviews = dashboardManagedJobs(cachedOverviews);
|
|
253
|
-
const operationalSnapshot = { ...snapshot, overviews: operational };
|
|
254
|
-
pullRequestRefreshCache.set(key, { snapshot: operationalSnapshot, completedAt: Date.now() });
|
|
255
|
-
return operationalSnapshot;
|
|
256
|
-
})();
|
|
257
|
-
pullRequestRefreshFlights.set(key, flight);
|
|
258
|
-
try {
|
|
259
|
-
return await flight;
|
|
260
|
-
}
|
|
261
|
-
finally {
|
|
262
|
-
if (pullRequestRefreshFlights.get(key) === flight)
|
|
263
|
-
pullRequestRefreshFlights.delete(key);
|
|
264
|
-
}
|
|
227
|
+
const overviews = async (_forceRefresh = false, _trigger = 'internal.unknown') => refreshAll();
|
|
228
|
+
const refreshPullRequestSnapshot = async (repository, prNumber, _trigger = 'internal', _options = {}) => {
|
|
229
|
+
const inventory = await refreshAll();
|
|
230
|
+
return {
|
|
231
|
+
overviews: inventory.filter(item => item.job.repository === repository && item.job.prNumber === prNumber),
|
|
232
|
+
seed: { prNode: {}, comments: null },
|
|
233
|
+
};
|
|
265
234
|
};
|
|
266
235
|
const refreshPullRequest = async (repository, prNumber, trigger, options) => (await refreshPullRequestSnapshot(repository, prNumber, trigger, options)).overviews;
|
|
267
|
-
const
|
|
236
|
+
const localJobBinding = createJobIdRegistryResolver(join(homedir(), '.agents-relay', 'stores'), relayStores);
|
|
237
|
+
const requireLocalJobBinding = async (jobId) => {
|
|
238
|
+
const binding = await localJobBinding(jobId);
|
|
239
|
+
if (!binding)
|
|
240
|
+
throw new Error('Local Job ' + jobId + ' not found or ambiguous in SQLite registry');
|
|
241
|
+
return binding;
|
|
242
|
+
};
|
|
243
|
+
const pool = new RepositoryWorkerPool(concurrency, makeReconciler, inventoryFromLocal, localJobBinding);
|
|
268
244
|
const initial = cachedOverviews;
|
|
269
245
|
const fallback = initial[0]?.job;
|
|
270
|
-
const dashboardStore = fallback ?
|
|
246
|
+
const dashboardStore = fallback ? (await requireLocalJobBinding(fallback.id)).store : new InMemoryStore();
|
|
271
247
|
const dashboardOverview = (jobId, target) => cachedOverviews.find(item => item.job.id === jobId
|
|
272
248
|
&& (!target?.repository || item.job.repository === target.repository)
|
|
273
249
|
&& (!target?.prNumber || item.job.prNumber === target.prNumber));
|
|
@@ -275,9 +251,9 @@ export async function runDaemon(argv) {
|
|
|
275
251
|
const overview = cachedOverviews.find(item => item.job.id === jobId);
|
|
276
252
|
if (!overview)
|
|
277
253
|
throw new Error(`Managed job ${jobId} not found`);
|
|
278
|
-
const store =
|
|
254
|
+
const store = (await requireLocalJobBinding(jobId)).store;
|
|
279
255
|
if (action === 'reconcile') {
|
|
280
|
-
await pool.
|
|
256
|
+
await pool.reconcileLocalJob(jobId, 'control.job.reconcile');
|
|
281
257
|
return await store.load(jobId);
|
|
282
258
|
}
|
|
283
259
|
return await dashboardJobAction(store, makeReconciler(store, concurrency), action)(jobId);
|
|
@@ -286,9 +262,9 @@ export async function runDaemon(argv) {
|
|
|
286
262
|
const overview = dashboardOverview(jobId, target);
|
|
287
263
|
if (!overview)
|
|
288
264
|
throw new Error(`Managed job ${jobId} not found`);
|
|
289
|
-
const store =
|
|
265
|
+
const store = (await requireLocalJobBinding(jobId)).store;
|
|
290
266
|
const result = await dashboardJobSetState(store)(jobId, state);
|
|
291
|
-
await
|
|
267
|
+
await refreshAll();
|
|
292
268
|
return result;
|
|
293
269
|
};
|
|
294
270
|
const workspaceTaskCreate = async (jobId, task) => {
|
|
@@ -297,7 +273,7 @@ export async function runDaemon(argv) {
|
|
|
297
273
|
throw new Error(`Managed job ${jobId} not found`);
|
|
298
274
|
if (task.jobId !== jobId)
|
|
299
275
|
throw new Error(`Task ${task.id} belongs to ${task.jobId}, not ${jobId}`);
|
|
300
|
-
const store =
|
|
276
|
+
const store = (await requireLocalJobBinding(jobId)).store;
|
|
301
277
|
const job = await store.load(jobId);
|
|
302
278
|
if (['COMPLETED', 'CANCELLED'].includes(job.state))
|
|
303
279
|
throw new Error(`Cannot create task on terminal job ${job.id} (${job.state})`);
|
|
@@ -307,14 +283,14 @@ export async function runDaemon(argv) {
|
|
|
307
283
|
await applyJobActivateAction(refreshed);
|
|
308
284
|
await store.saveJob(refreshed);
|
|
309
285
|
}
|
|
310
|
-
await pool.
|
|
286
|
+
await pool.reconcileLocalJob(jobId, 'control.task.create');
|
|
311
287
|
return await store.load(jobId);
|
|
312
288
|
};
|
|
313
289
|
const workspaceTaskStart = async (jobId, taskId) => {
|
|
314
290
|
const overview = cachedOverviews.find(item => item.job.id === jobId);
|
|
315
291
|
if (!overview)
|
|
316
292
|
throw new Error(`Managed job ${jobId} not found`);
|
|
317
|
-
const store =
|
|
293
|
+
const store = (await requireLocalJobBinding(jobId)).store;
|
|
318
294
|
const job = await store.load(jobId);
|
|
319
295
|
const task = job.tasks.find(item => item.id === taskId);
|
|
320
296
|
if (!task)
|
|
@@ -322,14 +298,14 @@ export async function runDaemon(argv) {
|
|
|
322
298
|
await applyOrchestratorStartAction(task);
|
|
323
299
|
await store.saveTask(task);
|
|
324
300
|
await publishOrchestratorTaskStarted(bus, job, task);
|
|
325
|
-
await pool.
|
|
301
|
+
await pool.reconcileLocalJob(jobId, 'control.task.start');
|
|
326
302
|
return await store.load(jobId);
|
|
327
303
|
};
|
|
328
304
|
const workspaceTaskComplete = async (jobId, taskId, summary) => {
|
|
329
305
|
const overview = cachedOverviews.find(item => item.job.id === jobId);
|
|
330
306
|
if (!overview)
|
|
331
307
|
throw new Error(`Managed job ${jobId} not found`);
|
|
332
|
-
const store =
|
|
308
|
+
const store = (await requireLocalJobBinding(jobId)).store;
|
|
333
309
|
const job = await store.load(jobId);
|
|
334
310
|
const task = job.tasks.find(item => item.id === taskId);
|
|
335
311
|
if (!task)
|
|
@@ -337,27 +313,27 @@ export async function runDaemon(argv) {
|
|
|
337
313
|
await applyOrchestratorCompleteAction(task, summary);
|
|
338
314
|
await store.saveTask(task);
|
|
339
315
|
await publishOrchestratorTaskCompletion(bus, job, task, summary);
|
|
340
|
-
await pool.
|
|
316
|
+
await pool.reconcileLocalJob(jobId, 'control.task.complete');
|
|
341
317
|
return await store.load(jobId);
|
|
342
318
|
};
|
|
343
319
|
const workspaceTaskAction = (action) => async (jobId, taskId) => {
|
|
344
320
|
const overview = cachedOverviews.find(item => item.job.id === jobId);
|
|
345
321
|
if (!overview)
|
|
346
322
|
throw new Error(`Managed job ${jobId} not found`);
|
|
347
|
-
const store =
|
|
323
|
+
const store = (await requireLocalJobBinding(jobId)).store;
|
|
348
324
|
return await dashboardTaskAction(store, makeReconciler(store, concurrency), action)(jobId, taskId);
|
|
349
325
|
};
|
|
350
326
|
const workspaceTaskSetState = async (jobId, taskId, state, target) => {
|
|
351
327
|
const overview = dashboardOverview(jobId, target);
|
|
352
328
|
if (!overview)
|
|
353
329
|
throw new Error(`Managed job ${jobId} not found`);
|
|
354
|
-
const store =
|
|
330
|
+
const store = (await requireLocalJobBinding(jobId)).store;
|
|
355
331
|
const result = await dashboardTaskSetState(store)(jobId, taskId, state);
|
|
356
|
-
await
|
|
332
|
+
await refreshAll();
|
|
357
333
|
return result;
|
|
358
334
|
};
|
|
359
335
|
const secretFile = value(argv, '--github-webhook-secret-file');
|
|
360
|
-
const secret = value(argv, '--github-webhook-secret') || process.env.AGENTS_RELAY_GITHUB_WEBHOOK_SECRET || (secretFile ? (await readFile(secretFile, 'utf8')).trim() : '');
|
|
336
|
+
const secret = noGitHub ? '' : value(argv, '--github-webhook-secret') || process.env.AGENTS_RELAY_GITHUB_WEBHOOK_SECRET || (secretFile ? (await readFile(secretFile, 'utf8')).trim() : '');
|
|
361
337
|
const webhook = secret ? {
|
|
362
338
|
secret,
|
|
363
339
|
repositories: new Set(repositories),
|
|
@@ -366,12 +342,25 @@ export async function runDaemon(argv) {
|
|
|
366
342
|
if (targets.length === 0)
|
|
367
343
|
return;
|
|
368
344
|
for (const target of targets) {
|
|
369
|
-
const
|
|
345
|
+
const binding = await requireLocalJobBinding(target.job.id);
|
|
346
|
+
const store = binding.store;
|
|
370
347
|
if (event.pullRequestFacts && store.applyPullRequestFacts) {
|
|
371
|
-
await store.applyPullRequestFacts({ ...event.pullRequestFacts,
|
|
372
|
-
|
|
348
|
+
await store.applyPullRequestFacts({ ...event.pullRequestFacts,
|
|
349
|
+
observedAt: event.pullRequestFacts.observedAt ?? new Date().toISOString() });
|
|
350
|
+
target.githubState = await store.pullRequestState?.() ?? target.githubState;
|
|
351
|
+
}
|
|
352
|
+
if (event.publication || event.publishFailure) {
|
|
353
|
+
const relay = binding.relay;
|
|
354
|
+
try {
|
|
355
|
+
const inserted = await relay.recordPublication(target.job.id, event.deliveryId, event.publication, event.publishFailure ?? null, event.publicationObservedAt);
|
|
356
|
+
// Replayed webhook deliveries may retry EventBus delivery; SQLite state remains idempotent.
|
|
357
|
+
const state = await relay.loadPublication(target.job.id);
|
|
358
|
+
target.publication = state?.latest ?? null;
|
|
359
|
+
target.publicationFailure = state?.failure ?? null;
|
|
360
|
+
}
|
|
361
|
+
finally { /* The SQLite registry owns the shared connection. */ }
|
|
373
362
|
}
|
|
374
|
-
|
|
363
|
+
// GitHub is an audit input only: no workflow or reconcile wake.
|
|
375
364
|
}
|
|
376
365
|
cachedOverviews = dashboardManagedJobs(cachedOverviews);
|
|
377
366
|
}
|
|
@@ -384,14 +373,12 @@ export async function runDaemon(argv) {
|
|
|
384
373
|
}
|
|
385
374
|
if (!cached)
|
|
386
375
|
return null;
|
|
387
|
-
const cachedJob = () => ({ ...cached.job, githubState: cached.githubState, runtimeStatus: deriveJobRuntimeStatus(cached.job, cached.githubState), mergedAt: cached.mergedAt, closedAt: cached.closedAt, draft: cached.draft, publication: cached.publication });
|
|
388
|
-
if (client.rateLimitStatus().limited)
|
|
389
|
-
return cachedJob();
|
|
376
|
+
const cachedJob = () => ({ ...cached.job, githubState: cached.githubState, runtimeStatus: deriveJobRuntimeStatus(cached.job, cached.githubState), mergedAt: cached.mergedAt, closedAt: cached.closedAt, draft: cached.draft, publication: cached.publication, publicationFailure: cached.publicationFailure });
|
|
390
377
|
try {
|
|
391
378
|
const refreshed = (await refreshPullRequest(cached.job.repository, cached.job.prNumber, 'dashboard.selected-job', { force: true })).find(item => item.job.id === jobId);
|
|
392
379
|
if (!refreshed)
|
|
393
380
|
return null;
|
|
394
|
-
return { ...refreshed.job, githubState: refreshed.githubState, runtimeStatus: deriveJobRuntimeStatus(refreshed.job, refreshed.githubState), mergedAt: refreshed.mergedAt, closedAt: refreshed.closedAt, draft: refreshed.draft, publication: refreshed.publication };
|
|
381
|
+
return { ...refreshed.job, githubState: refreshed.githubState, runtimeStatus: deriveJobRuntimeStatus(refreshed.job, refreshed.githubState), mergedAt: refreshed.mergedAt, closedAt: refreshed.closedAt, draft: refreshed.draft, publication: refreshed.publication, publicationFailure: refreshed.publicationFailure };
|
|
395
382
|
}
|
|
396
383
|
catch (error) {
|
|
397
384
|
observability().record({ stage: 'daemon', event: 'daemon.refresh_failed', level: 'warn', outcome: 'failed', message: `Selected job refresh unavailable: ${errorMessage(error)}`, data: { classification: classifyFailure(error).classification, reason: errorMessage(error) } });
|
|
@@ -399,14 +386,16 @@ export async function runDaemon(argv) {
|
|
|
399
386
|
}
|
|
400
387
|
};
|
|
401
388
|
const stopWatch = bus ? await pool.watch(bus) : async () => { };
|
|
402
|
-
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(), readPaths: { ...githubReadPaths } }), () => client.rateLimitSnapshot(), exactJob, {
|
|
389
|
+
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(), readPaths: { ...githubReadPaths } }), noGitHub ? undefined : () => client.rateLimitSnapshot(), exactJob, {
|
|
403
390
|
start: workspaceJobAction('start'),
|
|
404
391
|
reconcile: workspaceJobAction('reconcile'),
|
|
405
392
|
cancel: workspaceJobAction('cancel'),
|
|
406
393
|
jobSetState: workspaceJobSetState,
|
|
407
394
|
taskCreate: workspaceTaskCreate,
|
|
408
|
-
taskUpdate: managedTaskUpdateHandler(
|
|
409
|
-
|
|
395
|
+
taskUpdate: managedTaskUpdateHandler(async (jobId) => {
|
|
396
|
+
const binding = await requireLocalJobBinding(jobId);
|
|
397
|
+
return { store: binding.store, repository: binding.job.repository, prNumber: binding.job.prNumber };
|
|
398
|
+
}, async (_repository, _prNumber, jobId) => pool.reconcileLocalJob(jobId, 'control.task.update')),
|
|
410
399
|
taskStart: workspaceTaskStart,
|
|
411
400
|
taskRetry: workspaceTaskAction('retry'),
|
|
412
401
|
taskCancel: workspaceTaskAction('cancel'),
|
|
@@ -496,14 +485,18 @@ export async function runDaemon(argv) {
|
|
|
496
485
|
}
|
|
497
486
|
};
|
|
498
487
|
const flushProjectionsSafely = async () => {
|
|
488
|
+
let externalActionsCompleted = 0;
|
|
499
489
|
for (const writer of projectionWriters.values()) {
|
|
500
490
|
try {
|
|
501
|
-
await writer.flushOnce();
|
|
491
|
+
externalActionsCompleted += (await writer.flushOnce()).projected;
|
|
502
492
|
}
|
|
503
493
|
catch (error) {
|
|
504
|
-
observability().record({ stage: 'daemon', event: 'daemon.projection_flush_failed', level: 'warn', outcome: 'failed', message: `GitHub
|
|
494
|
+
observability().record({ stage: 'daemon', event: 'daemon.projection_flush_failed', level: 'warn', outcome: 'failed', message: `GitHub external action unavailable: ${errorMessage(error)}`, data: { classification: classifyFailure(error).classification, reason: errorMessage(error) } });
|
|
505
495
|
}
|
|
506
496
|
}
|
|
497
|
+
// GitHub external acknowledgements are audit-only. Never wake local Graphs.
|
|
498
|
+
if (externalActionsCompleted > 0)
|
|
499
|
+
observability().record({ stage: 'daemon', event: 'github.external_actions_recorded', level: 'info', outcome: 'succeeded', message: String(externalActionsCompleted) + ' external actions acknowledged without lifecycle reconciliation' });
|
|
507
500
|
};
|
|
508
501
|
const watchdogDefault = webhook ? SERVICE_DEFAULTS.webhookWatchdogMs : SERVICE_DEFAULTS.watchdogMs;
|
|
509
502
|
let interval;
|
|
@@ -521,14 +514,16 @@ export async function runDaemon(argv) {
|
|
|
521
514
|
await flushProjectionsSafely();
|
|
522
515
|
interval = setInterval(() => void reconcileSafely().finally(flushProjectionsSafely), Number(value(argv, '--interval', String(watchdogDefault))));
|
|
523
516
|
interval.unref();
|
|
524
|
-
|
|
525
|
-
|
|
517
|
+
if (!noGitHub) {
|
|
518
|
+
projectionInterval = setInterval(() => void flushProjectionsSafely(), Number(value(argv, '--projection-ms', String(SERVICE_DEFAULTS.projectionFlushMs))));
|
|
519
|
+
projectionInterval.unref();
|
|
520
|
+
}
|
|
526
521
|
scheduleInterval = setInterval(() => void scheduleHeartbeatSafely(), SCHEDULE_HEARTBEAT_MS);
|
|
527
522
|
scheduleInterval.unref();
|
|
528
523
|
console.log('Dashboard listening; repository worker pool active');
|
|
529
524
|
void refreshAll(true)
|
|
530
525
|
.then(items => pool.reconcileKnown(items))
|
|
531
|
-
.catch(error => observability().record({ stage: 'daemon', event: 'daemon.startup_refresh_failed', level: 'warn', outcome: 'failed', message: 'Startup
|
|
526
|
+
.catch(error => observability().record({ stage: 'daemon', event: 'daemon.startup_refresh_failed', level: 'warn', outcome: 'failed', message: 'Startup local refresh unavailable: ' + errorMessage(error), data: { classification: classifyFailure(error).classification, reason: errorMessage(error) } }));
|
|
532
527
|
}
|
|
533
528
|
if (isEntrypoint(import.meta.url))
|
|
534
529
|
runDaemon(process.argv.slice(2)).catch(error => { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; });
|
package/dist/runtime-status.js
CHANGED
|
@@ -12,11 +12,12 @@ export function implementationComplete(job) {
|
|
|
12
12
|
const tasks = implementationTasks(job);
|
|
13
13
|
return tasks.length > 0 && tasks.every(task => task.state === 'SUCCEEDED');
|
|
14
14
|
}
|
|
15
|
-
export function deriveJobRuntimeStatus(job,
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
if (prState === 'CLOSED' || job.state === 'CANCELLED')
|
|
15
|
+
export function deriveJobRuntimeStatus(job, _prState = 'OPEN') {
|
|
16
|
+
// External PR state is display metadata, never a Job runtime state input.
|
|
17
|
+
if (job.state === 'CANCELLED')
|
|
19
18
|
return 'CANCELLED';
|
|
19
|
+
if (job.state === 'COMPLETED')
|
|
20
|
+
return 'COMPLETED';
|
|
20
21
|
if (job.state === 'DRAFT')
|
|
21
22
|
return 'IDLE';
|
|
22
23
|
if (job.schedule) {
|
|
@@ -39,7 +40,7 @@ export function deriveJobRuntimeStatus(job, prState = 'OPEN') {
|
|
|
39
40
|
if (implementation.some(task => task.state === 'QUEUED'))
|
|
40
41
|
return 'QUEUED';
|
|
41
42
|
const reviewRunning = job.tasks.some(task => task.agentName === 'reviewer' && task.state === 'RUNNING');
|
|
42
|
-
if (job.review?.outcome === 'approved'
|
|
43
|
+
if (job.review?.outcome === 'approved')
|
|
43
44
|
return 'MERGE_PENDING';
|
|
44
45
|
if (reviewRunning)
|
|
45
46
|
return 'REVIEWING';
|
|
@@ -53,9 +54,9 @@ export function deriveJobRuntimeStatus(job, prState = 'OPEN') {
|
|
|
53
54
|
return 'FAILED';
|
|
54
55
|
if (job.tasks.some(task => task.state === 'DRAFT'))
|
|
55
56
|
return 'WAITING';
|
|
56
|
-
if (job.executionMode === 'autonomous' && job.autoMerge === false && job.reviewEnabled === false && implementationComplete(job)
|
|
57
|
+
if (job.executionMode === 'autonomous' && job.autoMerge === false && job.reviewEnabled === false && implementationComplete(job))
|
|
57
58
|
return 'WAITING';
|
|
58
|
-
if (job.reviewEnabled === false && implementationComplete(job)
|
|
59
|
+
if (job.reviewEnabled === false && implementationComplete(job))
|
|
59
60
|
return 'MERGE_PENDING';
|
|
60
61
|
return 'IDLE';
|
|
61
62
|
}
|
package/dist/scheduled-jobs.js
CHANGED
|
@@ -1,19 +1,31 @@
|
|
|
1
|
-
import { GitHubStore } from './store.js';
|
|
2
1
|
import { nextScheduleRun, scheduleBucket } from './scheduler.js';
|
|
3
2
|
import { dispatchScheduledRun } from './workflow/scheduled-run-v2.js';
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
3
|
+
/** Local-first schedule entrypoint: immutable Job ID selects the SQLite store. */
|
|
4
|
+
export async function runScheduledBucketFromStore(store, ref, engine, now = new Date()) {
|
|
5
|
+
// SQLite authority provides an atomic claim. The fallback retains the
|
|
6
|
+
// existing in-memory test adapter, but cannot be used for production claims.
|
|
7
|
+
if (store.claimScheduledSlot) {
|
|
8
|
+
if (!store.finishScheduledSlot)
|
|
9
|
+
throw new Error('Atomic scheduled completion is required');
|
|
10
|
+
const claimed = await store.claimScheduledSlot(ref.jobId, ref.bucket, now);
|
|
11
|
+
if (claimed.status !== 'completed')
|
|
12
|
+
return claimed;
|
|
13
|
+
const slot = claimed.job.schedule?.currentRunSlot;
|
|
14
|
+
if (!slot)
|
|
15
|
+
throw new Error('Claimed schedule slot missing durable currentRunSlot');
|
|
16
|
+
await engine.reconcile(ref.jobId);
|
|
17
|
+
await engine.idle();
|
|
18
|
+
const job = await store.finishScheduledSlot(ref.jobId, slot, now);
|
|
19
|
+
return { job, status: 'completed' };
|
|
20
|
+
}
|
|
21
|
+
let job = await store.load(ref.jobId);
|
|
22
|
+
if (!job.schedule)
|
|
12
23
|
throw new Error('Scheduled job ' + job.id + ' has no schedule metadata');
|
|
24
|
+
const schedule = job.schedule;
|
|
13
25
|
const slot = new Date(schedule.nextRunAt);
|
|
14
26
|
const active = job.tasks.some(task => ['QUEUED', 'RUNNING'].includes(task.state));
|
|
15
|
-
|
|
16
|
-
let
|
|
27
|
+
let claimed = false;
|
|
28
|
+
let status = 'skipped-idempotent';
|
|
17
29
|
const decision = await dispatchScheduledRun({
|
|
18
30
|
slotDue: scheduleBucket(slot) === ref.bucket && slot.getTime() <= now.getTime(),
|
|
19
31
|
slotUnclaimed: !schedule.lastScheduledSlot || new Date(schedule.lastScheduledSlot).getTime() < slot.getTime(),
|
|
@@ -24,37 +36,32 @@ export async function runScheduledBucket(client, repository, trustedAuthors, ref
|
|
|
24
36
|
if (actionId === 'schedule.skipActiveAdvance') {
|
|
25
37
|
schedule.lastScheduledSlot = slot.toISOString();
|
|
26
38
|
schedule.nextRunAt = nextScheduleRun(schedule, now).toISOString();
|
|
27
|
-
|
|
28
|
-
await schedulerStore.saveJob(job);
|
|
29
|
-
actionStatus = 'skipped-active';
|
|
30
|
-
return {};
|
|
39
|
+
status = 'skipped-active';
|
|
31
40
|
}
|
|
32
|
-
if (actionId === 'schedule.claimFromSlot' || actionId === 'schedule.claimFromNow') {
|
|
41
|
+
else if (actionId === 'schedule.claimFromSlot' || actionId === 'schedule.claimFromNow') {
|
|
33
42
|
schedule.lastScheduledSlot = slot.toISOString();
|
|
34
43
|
schedule.currentRunSlot = slot.toISOString();
|
|
35
44
|
schedule.nextRunAt = nextScheduleRun(schedule, actionId === 'schedule.claimFromSlot' ? slot : now).toISOString();
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
job = await schedulerStore.load(job.id);
|
|
39
|
-
return {};
|
|
45
|
+
claimed = true;
|
|
46
|
+
status = 'completed';
|
|
40
47
|
}
|
|
41
|
-
|
|
48
|
+
else
|
|
49
|
+
throw new Error('Unknown local schedule action: ' + actionId);
|
|
50
|
+
job.updatedAt = now.toISOString();
|
|
51
|
+
await store.saveJob(job);
|
|
52
|
+
return {};
|
|
42
53
|
});
|
|
43
|
-
if (!decision.accepted ||
|
|
44
|
-
return { job, status
|
|
45
|
-
if (actionStatus === 'skipped-active' || decision.target === 'skip_active')
|
|
46
|
-
return { job, status: 'skipped-active' };
|
|
47
|
-
if (decision.target !== 'claimed')
|
|
48
|
-
return { job, status: 'skipped-idempotent' };
|
|
49
|
-
if (!engine)
|
|
50
|
-
throw new Error('Scheduled jobs require the local relayd scheduler engine');
|
|
54
|
+
if (!decision.accepted || !claimed)
|
|
55
|
+
return { job, status };
|
|
51
56
|
await engine.reconcile(job.id);
|
|
52
57
|
await engine.idle();
|
|
53
|
-
|
|
54
|
-
if (!
|
|
55
|
-
throw new Error('Scheduled job
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
58
|
+
job = await store.load(job.id);
|
|
59
|
+
if (!job.schedule)
|
|
60
|
+
throw new Error('Scheduled job lost schedule metadata');
|
|
61
|
+
job.schedule.lastRunAt = now.toISOString();
|
|
62
|
+
// A claim and its completion can share the same wall-clock timestamp.
|
|
63
|
+
// Advance the snapshot revision to satisfy SQLite's stale-write guard.
|
|
64
|
+
job.updatedAt = new Date(Math.max(now.getTime(), Date.parse(job.updatedAt) + 1)).toISOString();
|
|
65
|
+
await store.saveJob(job);
|
|
66
|
+
return { job, status: 'completed' };
|
|
60
67
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { LocalAuthorityDurableStore } from './local-authority-store.js';
|
|
2
|
+
import { errorMessage, observability } from './observability.js';
|
|
3
|
+
export async function flushServiceExternalActions(store, reconciler, jobId) {
|
|
4
|
+
if (!(store instanceof LocalAuthorityDurableStore) || store.noGitHub)
|
|
5
|
+
return 0;
|
|
6
|
+
const result = await store.projection.flushOnce();
|
|
7
|
+
if (result.projected > 0)
|
|
8
|
+
await reconciler.reconcile(jobId);
|
|
9
|
+
return result.projected;
|
|
10
|
+
}
|
|
11
|
+
export function startServiceExternalActionFlusher(store, reconciler, jobId, intervalMs) {
|
|
12
|
+
if (!(store instanceof LocalAuthorityDurableStore) || store.noGitHub)
|
|
13
|
+
return () => { };
|
|
14
|
+
const tick = () => {
|
|
15
|
+
void flushServiceExternalActions(store, reconciler, jobId).catch(error => observability().record({
|
|
16
|
+
stage: 'reconcile',
|
|
17
|
+
event: 'service.external_action_flush_failed',
|
|
18
|
+
level: 'warn',
|
|
19
|
+
outcome: 'failed',
|
|
20
|
+
message: errorMessage(error),
|
|
21
|
+
}));
|
|
22
|
+
};
|
|
23
|
+
tick();
|
|
24
|
+
const timer = setInterval(tick, intervalMs);
|
|
25
|
+
timer.unref();
|
|
26
|
+
return () => clearInterval(timer);
|
|
27
|
+
}
|