agents-relay 2.0.66 → 2.0.68

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +24 -19
  2. package/dist/adapters.js +1 -1
  3. package/dist/cli-blocker.js +3 -5
  4. package/dist/cli-help.js +12 -10
  5. package/dist/cli-job-create.js +69 -0
  6. package/dist/cli-local-merge-config.js +9 -0
  7. package/dist/cli-support.js +25 -8
  8. package/dist/cli.js +66 -80
  9. package/dist/dashboard-html.js +1 -1
  10. package/dist/dashboard.js +1 -1
  11. package/dist/github-auth.js +4 -3
  12. package/dist/github-webhook.js +13 -5
  13. package/dist/local-authority-store.js +279 -0
  14. package/dist/local-git-merge.js +113 -0
  15. package/dist/local-job-path.js +7 -0
  16. package/dist/local-job-registry.js +23 -0
  17. package/dist/local-managed-store.js +21 -0
  18. package/dist/local-merge-delivery.js +52 -0
  19. package/dist/local-merge-lock.js +103 -0
  20. package/dist/no-github.js +11 -0
  21. package/dist/planned-review-head.js +7 -8
  22. package/dist/pool.js +72 -135
  23. package/dist/projected-store.js +20 -3
  24. package/dist/projection-outbox.js +3 -1
  25. package/dist/publication-store.js +37 -0
  26. package/dist/reconciler-review.js +25 -11
  27. package/dist/reconciler-runtime.js +15 -5
  28. package/dist/reconciler.js +33 -60
  29. package/dist/relay-store.js +108 -6
  30. package/dist/relayd.js +119 -124
  31. package/dist/runtime-status.js +8 -7
  32. package/dist/scheduled-jobs.js +43 -36
  33. package/dist/service-external-actions.js +27 -0
  34. package/dist/service-publication.js +29 -0
  35. package/dist/store-github.js +17 -6
  36. package/dist/task-update.js +1 -1
  37. package/dist/workflow/contracts.js +10 -1
  38. package/dist/workflow/definitions-v2.js +2 -0
  39. package/dist/workflow/job-reconcile-v2.js +9 -60
  40. package/dist/workflow/runtime.js +18 -3
  41. package/package.json +5 -3
  42. package/workflow/job-lifecycle.mmd +19 -60
  43. package/workflow/review-lifecycle.mmd +2 -8
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, aggregateManagedGitHubJobs } from './pool.js';
9
+ import { RepositoryWorkerPool } from './pool.js';
10
10
  import { Reconciler } from './reconciler.js';
11
- import { dashboardManagedJobs, GitHubStore, InMemoryStore, loadManagedGitHubPullRequestSnapshotGraphql, withGitHubRequestContext } from './store.js';
12
- import { ProjectedDurableStore } from './projected-store.js';
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 { githubAuthContext } from './github-auth.js';
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.githubState === 'OPEN' && item.job.executionMode === 'autonomous' && item.job.schedule && Date.parse(item.job.schedule.nextRunAt) <= now.getTime());
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 => item.githubState === 'OPEN' && !['COMPLETED', 'CANCELLED'].includes(item.job.state)))
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
- const auth = await githubAuthContext(argv);
154
- const client = auth.client;
155
- const trusted = auth.trustedAuthors;
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 = normalized.workspaceRoot ?? defaultWorkspaceRoot();
161
- const discoveredRepositories = normalized.repository ? [] : await discoverWorkspaceRepositories(workspaceRoot);
162
- const repositories = normalized.repository ? [normalized.repository] : discoveredRepositories.map(item => item.repository);
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 ProjectedDurableStore(relay, github, seed ?? null, (relatedRepository, relatedPrNumber) => runtimeStore(relatedRepository, relatedPrNumber), conflict => {
181
- process.stderr.write(`projection conflict for ${repository}#${prNumber}: ${conflict.message} (fields: ${conflict.data.divergingFields.join(', ') || 'unknown'}; repair with agents-relay projection resolve)\n`);
182
- if (bus) {
183
- const jobId = conflict.data.jobId || conflictJobId(repository, prNumber);
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
- if (repositories.length === 0)
191
- throw new Error(`No Git repositories with GitHub origins found under ${normalized.workspaceRoot ?? defaultWorkspaceRoot()}`);
192
- const localSnapshots = await discoverLocalRelayStoreSnapshots(join(homedir(), '.agents-relay', 'stores'));
193
- const knownRepositories = new Set(repositories);
194
- cachedOverviews = dashboardManagedJobs(localSnapshots.flatMap(snapshot => {
195
- if (!snapshot.facts)
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
- const overviews = async (forceRefresh = false, _trigger = 'internal.unknown') => {
221
- if (forceRefresh)
222
- return refreshAll(true);
223
- cachedOverviews = await overlayOperationalOverviews(cachedOverviews, (repository, prNumber) => runtimeStore(repository, prNumber));
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 pullRequestRefreshFlights = new Map();
227
- const pullRequestRefreshCache = new Map();
228
- const incrementTrigger = (counts, trigger) => { counts[trigger] = (counts[trigger] ?? 0) + 1; };
229
- const refreshPullRequestSnapshot = async (repository, prNumber, trigger = 'internal', options = {}) => {
230
- if (client.rateLimitStatus().limited)
231
- return { overviews: cachedOverviews.filter(item => item.job.repository === repository && item.job.prNumber === prNumber), seed: { prNode: {}, comments: null } };
232
- const key = `${repository}#${prNumber}`;
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 pool = new RepositoryWorkerPool(client, repositories, trusted, concurrency, makeReconciler, () => client.rateLimitStatus().limited, (repository, prNumber, trigger) => refreshPullRequestSnapshot(repository, prNumber, trigger), (repository, prNumber, seed) => runtimeStore(repository, prNumber, seed));
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 ? runtimeStore(fallback.repository, fallback.prNumber) : new InMemoryStore();
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 = runtimeStore(overview.job.repository, overview.job.prNumber);
254
+ const store = (await requireLocalJobBinding(jobId)).store;
279
255
  if (action === 'reconcile') {
280
- await pool.reconcileTarget(overview.job.repository, overview.job.prNumber, jobId);
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 = runtimeStore(overview.job.repository, overview.job.prNumber);
265
+ const store = (await requireLocalJobBinding(jobId)).store;
290
266
  const result = await dashboardJobSetState(store)(jobId, state);
291
- await refreshPullRequest(overview.job.repository, overview.job.prNumber, 'dashboard.job-state', { force: true });
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 = runtimeStore(overview.job.repository, overview.job.prNumber);
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.reconcileTarget(overview.job.repository, overview.job.prNumber, jobId, 'control.task.create');
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 = runtimeStore(overview.job.repository, overview.job.prNumber);
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.reconcileTarget(overview.job.repository, overview.job.prNumber, jobId, 'control.task.start');
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 = runtimeStore(overview.job.repository, overview.job.prNumber);
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.reconcileTarget(overview.job.repository, overview.job.prNumber, jobId, 'control.task.complete');
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 = runtimeStore(overview.job.repository, overview.job.prNumber);
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 = runtimeStore(overview.job.repository, overview.job.prNumber);
330
+ const store = (await requireLocalJobBinding(jobId)).store;
355
331
  const result = await dashboardTaskSetState(store)(jobId, taskId, state);
356
- await refreshPullRequest(overview.job.repository, overview.job.prNumber, 'dashboard.task-state', { force: true });
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 store = runtimeStore(event.repository, event.pullRequest);
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, observedAt: new Date().toISOString() });
372
- target.githubState = event.pullRequestFacts.state;
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
- await pool.webhookWake(bus, target.job, event);
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(jobId => { const row = cachedOverviews.find(item => item.job.id === jobId); if (!row)
409
- throw new Error('Job not found'); return { store: runtimeStore(row.job.repository, row.job.prNumber), repository: row.job.repository, prNumber: row.job.prNumber }; }, (repository, prNumber, jobId) => pool.reconcileTarget(repository, prNumber, jobId, 'control.task.update')),
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 projection flush unavailable: ${errorMessage(error)}`, data: { classification: classifyFailure(error).classification, reason: errorMessage(error) } });
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
- projectionInterval = setInterval(() => void flushProjectionsSafely(), Number(value(argv, '--projection-ms', String(SERVICE_DEFAULTS.projectionFlushMs))));
525
- projectionInterval.unref();
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 remote refresh unavailable: ' + errorMessage(error), data: { classification: classifyFailure(error).classification, reason: errorMessage(error) } }));
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; });
@@ -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, prState = 'OPEN') {
16
- if (prState === 'MERGED')
17
- return 'MERGED';
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' && prState === 'OPEN')
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) && prState === 'OPEN')
57
+ if (job.executionMode === 'autonomous' && job.autoMerge === false && job.reviewEnabled === false && implementationComplete(job))
57
58
  return 'WAITING';
58
- if (job.reviewEnabled === false && implementationComplete(job) && prState === 'OPEN')
59
+ if (job.reviewEnabled === false && implementationComplete(job))
59
60
  return 'MERGE_PENDING';
60
61
  return 'IDLE';
61
62
  }
@@ -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
- async function refresh(client, repository, trustedAuthors, ref, storeFactory) {
5
- const store = storeFactory ? storeFactory() : new GitHubStore(client, repository, ref.prNumber, trustedAuthors);
6
- return await store.load(ref.jobId);
7
- }
8
- export async function runScheduledBucket(client, repository, trustedAuthors, ref, engine, now = new Date(), storeFactory) {
9
- let job = await refresh(client, repository, trustedAuthors, ref, storeFactory);
10
- const schedule = job.schedule;
11
- if (!schedule)
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
- const schedulerStore = storeFactory ? storeFactory() : new GitHubStore(client, repository, ref.prNumber, trustedAuthors);
16
- let actionStatus = null;
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
- job.updatedAt = now.toISOString();
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
- job.updatedAt = now.toISOString();
37
- await schedulerStore.saveJob(job);
38
- job = await schedulerStore.load(job.id);
39
- return {};
45
+ claimed = true;
46
+ status = 'completed';
40
47
  }
41
- throw new Error('Unsupported schedule graph action ' + actionId);
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 || decision.target === 'skip_idempotent')
44
- return { job, status: 'skipped-idempotent' };
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
- const finished = await schedulerStore.load(job.id);
54
- if (!finished.schedule)
55
- throw new Error('Scheduled job ' + job.id + ' lost its schedule metadata');
56
- finished.schedule.lastRunAt = now.toISOString();
57
- finished.updatedAt = now.toISOString();
58
- await schedulerStore.saveJob(finished);
59
- return { job: finished, status: 'completed' };
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
+ }