agents-relay 2.0.68 → 2.0.70

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 (56) hide show
  1. package/README.md +32 -1
  2. package/dist/adapters.js +39 -3
  3. package/dist/blockers.js +17 -4
  4. package/dist/cli-blocker.js +12 -28
  5. package/dist/cli-help.js +11 -8
  6. package/dist/cli-job-create.js +19 -13
  7. package/dist/cli-local-merge-config.js +4 -2
  8. package/dist/cli-projection.js +23 -55
  9. package/dist/cli-review.js +7 -24
  10. package/dist/cli-support.js +59 -4
  11. package/dist/cli-worker-task.js +63 -0
  12. package/dist/cli.js +179 -129
  13. package/dist/dashboard-html.js +15 -12
  14. package/dist/dashboard.js +26 -1
  15. package/dist/e2e-injection.js +76 -0
  16. package/dist/github-binding-order.js +15 -0
  17. package/dist/github-client.js +7 -2
  18. package/dist/github-pr-archive.js +22 -0
  19. package/dist/github-webhook.js +31 -6
  20. package/dist/job-actions.js +29 -0
  21. package/dist/local-authority-store.js +10 -1
  22. package/dist/local-commit-events.js +2 -0
  23. package/dist/local-git-merge.js +17 -6
  24. package/dist/local-job-path.js +5 -0
  25. package/dist/local-job-registry.js +10 -4
  26. package/dist/local-managed-store.js +2 -4
  27. package/dist/local-merge-delivery.js +3 -2
  28. package/dist/local-merge-projection.js +60 -0
  29. package/dist/managed-worker-input.js +13 -6
  30. package/dist/manual-plan.js +29 -7
  31. package/dist/planner-runtime.js +182 -41
  32. package/dist/planner.js +35 -2
  33. package/dist/pool.js +22 -6
  34. package/dist/reconciler-cancellation.js +21 -0
  35. package/dist/reconciler-review.js +74 -18
  36. package/dist/reconciler-runtime.js +69 -34
  37. package/dist/reconciler.js +92 -26
  38. package/dist/relay-store.js +1 -1
  39. package/dist/relayd-control.js +184 -0
  40. package/dist/relayd-dashboard-actions.js +108 -0
  41. package/dist/relayd-utilities.js +107 -0
  42. package/dist/relayd.js +220 -210
  43. package/dist/review-generation-fence.js +12 -0
  44. package/dist/runtime-status-events.js +21 -0
  45. package/dist/runtime-status.js +13 -0
  46. package/dist/service-publication.js +2 -4
  47. package/dist/task-actions.js +59 -1
  48. package/dist/troubleshooter.js +21 -2
  49. package/dist/unblock.js +47 -15
  50. package/dist/worker-rejection.js +11 -0
  51. package/dist/workflow-effect-failures.js +30 -0
  52. package/dist/workspace.js +95 -6
  53. package/package.json +5 -4
  54. package/skills/agents-relay/agents/planner.agent.md +3 -0
  55. package/skills/agents-relay/contracts/orchestrator-workflows.md +4 -7
  56. package/workflow/review-lifecycle.mmd +3 -0
package/dist/relayd.js CHANGED
@@ -1,5 +1,7 @@
1
1
  #!/usr/bin/env node
2
+ import { consumeMethodInjection, withTestInjection } from './e2e-injection.js';
2
3
  import { once } from 'node:events';
4
+ import { spawnOwnedProcess } from './owned-process.js';
3
5
  import { readFile } from 'node:fs/promises';
4
6
  import { homedir } from 'node:os';
5
7
  import { join } from 'node:path';
@@ -8,133 +10,45 @@ import { CodexThreadContinuation, CommandContinuation, WebhookContinuation } fro
8
10
  import { serveDashboard } from './dashboard.js';
9
11
  import { RepositoryWorkerPool } from './pool.js';
10
12
  import { Reconciler } from './reconciler.js';
11
- import { dashboardManagedJobs, GitHubStore, InMemoryStore } from './store.js';
13
+ import { dashboardManagedJobs, GitHubStore, InMemoryStore, resolvePullRequest } from './store.js';
14
+ import { GitHubProjectionWriter } from './projected-store.js';
12
15
  import { LocalAuthorityDurableStore } from './local-authority-store.js';
13
- import { discoverLocalRelayStoreSnapshots, relayStorePath, SQLiteRelayStore } from './relay-store.js';
16
+ import { discoverLocalRelayStoreSnapshots, relayStoreDirectory, relayStorePath, SQLiteRelayStore } from './relay-store.js';
14
17
  import { createJobIdRegistryResolver } from './local-job-registry.js';
15
- import { GhApiClient } from './github-client.js';
18
+ import { projectAuthoritativeLocalMerge } from './local-merge-projection.js';
19
+ import { archiveDisplayPullRequest } from './github-pr-archive.js';
20
+ import { orderGitHubBindingCandidates } from './github-binding-order.js';
21
+ import { githubAuthContext } from './github-auth.js';
16
22
  import { DisabledGitHubClient, noGitHubMode } from './no-github.js';
17
23
  import { basename } from 'node:path';
18
24
  import { dashboardJobAction, isEntrypoint, runtimePlanner, runtimeReviewer, startService, SERVICE_DEFAULTS } from './cli.js';
19
- import { eventBusResolutionMessage, publishOrchestratorTaskStarted, publishOrchestratorTaskCompletion, resolveEventBus } from './cli-support.js';
25
+ import { eventBusResolutionMessage, publishOrchestratorTaskStarted, publishOrchestratorTaskCompletion, publishTaskUnblocked, resolveEventBus } from './cli-support.js';
26
+ import { eventFor } from './events.js';
27
+ import { dispatchTaskAction } from './task-actions.js';
28
+ import { isEffectivelyBlocked, unblockAutonomousTasks, unblockTask } from './unblock.js';
29
+ import { registerBlockerDependency } from './blockers.js';
30
+ import { ensureManagedGitHubJob } from './cli-job-create.js';
20
31
  import { applyJobActivateAction } from './job-actions.js';
21
32
  import { applyOrchestratorStartAction, applyOrchestratorCompleteAction } from './task-actions.js';
22
33
  import { managedTaskUpdateHandler } from './task-update.js';
23
34
  import { dashboardJobSetState, dashboardTaskAction, dashboardTaskSetState } from './dashboard-actions.js';
24
35
  import { codexAndZaiUsageRegistry } from './usage.js';
25
- import { defaultWorkspaceRoot, discoverWorkspaceRepositories, resolveManagedProjectPath, resolveRepositoryProjectPath } from './workspace.js';
36
+ import { defaultWorkspaceRoot, discoverWorkspaceRepositories, ensureLocalJobSourceBranch, prepareLocalJobExecutionWorktree, resolveManagedProjectPath, resolveRepositoryProjectPath } from './workspace.js';
26
37
  import { deriveJobRuntimeStatus } from './runtime-status.js';
38
+ import { createRuntimeStatusPublisher } from './runtime-status-events.js';
39
+ import { subscribeLocalCommits } from './local-commit-events.js';
27
40
  import { decisionMode, executionDecisionEngine } from './decision.js';
28
41
  import { JsonlDecisionAuditStore } from './decision-audit.js';
29
42
  import { summarizeRuntime } from './runtime-summary.js';
30
- import { executionRouterRulesPath } from './routing-policy.js';
31
43
  import { workerCandidates } from './worker-routing.js';
44
+ import { discoverAgents } from './registry.js';
32
45
  import { shutdownOwnedProcesses } from './owned-process.js';
33
46
  import { routeExecution } from './execution-routing.js';
34
- import { RelayObservability, classifyFailure, errorMessage, observability, setObservability } from './observability.js';
35
- /** Initialize the process-wide structured observability sink once, from environment. */
36
- export function configureDaemonObservability(env = process.env) {
37
- const configured = RelayObservability.fromEnvironment(env);
38
- setObservability(configured);
39
- return configured;
40
- }
41
- export async function overlayOperationalOverviews(overviews, storeFor) {
42
- return await Promise.all(overviews.map(async (item) => {
43
- const store = storeFor(item.job.repository, item.job.prNumber);
44
- await store.hydrate?.(item.job);
45
- const job = await store.load(item.job.id);
46
- return { ...item, job };
47
- }));
48
- }
49
- function managedOverviewKey(item) {
50
- return item.job.repository + '#' + item.job.prNumber + ':' + item.job.id;
51
- }
52
- export function mergeOperationalInventory(current, discovered) {
53
- const merged = new Map(current.map(item => [managedOverviewKey(item), item]));
54
- for (const item of discovered)
55
- merged.set(managedOverviewKey(item), item);
56
- return [...merged.values()];
57
- }
58
- const PR_REFRESH_COALESCE_MS = 1000;
59
- export const SCHEDULE_HEARTBEAT_MS = 600_000;
60
- export function dueScheduledOverviews(overviews, now = new Date()) {
61
- return overviews.filter(item => item.job.state === 'ACTIVE' && item.job.executionMode === 'autonomous' && item.job.schedule && Date.parse(item.job.schedule.nextRunAt) <= now.getTime());
62
- }
63
- export function shouldCoalescePullRequestRefresh(completedAt, now = Date.now(), force = false) {
64
- return !force && completedAt !== null && now >= completedAt && now - completedAt < PR_REFRESH_COALESCE_MS;
65
- }
66
- export const daemonHelp = `Agents Relay daemon runs the dashboard and worker pool.
67
-
68
- Usage:
69
- npx agents-relayd [options]
70
-
71
- Options:
72
- --no-github Local-only mode: no GitHub auth, transport, PR, merge, or fetch
73
- --repo OWNER/REPO Watch one repository
74
- --workspace PATH Discover repositories below PATH (default: ~/Workspace)
75
- --port PORT Dashboard port (default: 8787)
76
- --concurrency N Maximum concurrent workers (default: 4)
77
- --interval MS Recovery interval
78
- --projection-ms MS GitHub projection flush interval (default: 1000)
79
- --execution-router-rules PATH
80
- Router Rules file (default: ~/.agents-relay/execution-router-rules.md)
81
- --events nats Require NATS event wakeups (otherwise auto-detect reachable NATS)
82
-
83
- Legacy single-job mode also accepts --repo OWNER/REPO --pr NUMBER --id JOB_ID.
84
-
85
- Examples:
86
- npx agents-relayd --repo OWNER/REPO
87
- npx agents-relayd --workspace ~/Workspace`;
88
- function firstStageChoices(decisionInput) {
89
- const stages = decisionInput?.stages;
90
- if (!Array.isArray(stages) || stages.length === 0)
91
- return [];
92
- const contract = stages[0].contract;
93
- return Array.isArray(contract?.choices) ? contract.choices.filter((item) => typeof item === 'string') : [];
94
- }
95
- function value(args, name, fallback = '') { const index = args.indexOf(name); return index >= 0 ? args[index + 1] ?? fallback : fallback; }
96
- export function normalizeDaemonArguments(argv) {
97
- const repository = value(argv, '--repo') || null;
98
- const workspaceRoot = value(argv, '--workspace', defaultWorkspaceRoot()) || null;
99
- const prValue = value(argv, '--pr');
100
- const id = value(argv, '--id');
101
- if ((prValue && !id) || (!prValue && id))
102
- throw new Error('--pr and --id must be supplied together for legacy single-job mode');
103
- if ((prValue || id) && !repository)
104
- throw new Error('--repo is required for legacy single-job mode');
105
- if (repository && argv.includes('--workspace'))
106
- throw new Error('--repo and --workspace are mutually exclusive');
107
- const pullRequest = prValue ? Number(prValue) : null;
108
- if (pullRequest !== null && (!Number.isInteger(pullRequest) || pullRequest <= 0))
109
- throw new Error('--pr must be positive');
110
- return { repository, workspaceRoot: repository ? null : workspaceRoot, pullRequest, jobId: id || null, serviceArguments: [...argv] };
111
- }
112
- export function recoveryScanRepositories(repositories, cached, dormantProbeIndex = 0) {
113
- const byRepository = new Map();
114
- for (const item of cached) {
115
- const values = byRepository.get(item.job.repository) ?? [];
116
- values.push(item);
117
- byRepository.set(item.job.repository, values);
118
- }
119
- const activeOrUnknown = [];
120
- const dormant = [];
121
- for (const repository of repositories) {
122
- const values = byRepository.get(repository);
123
- if (!values || values.length === 0 || values.some(item => !['COMPLETED', 'CANCELLED'].includes(item.job.state)))
124
- activeOrUnknown.push(repository);
125
- else
126
- dormant.push(repository);
127
- }
128
- if (dormant.length === 0)
129
- return activeOrUnknown;
130
- return [...activeOrUnknown, dormant[Math.abs(dormantProbeIndex) % dormant.length]];
131
- }
132
- export function configureExecutionRouterRulesPath(argv) {
133
- const explicitRulesPath = value(argv, '--execution-router-rules') || undefined;
134
- if (explicitRulesPath)
135
- process.env.AGENTS_RELAY_EXECUTION_ROUTER_RULES = executionRouterRulesPath(explicitRulesPath);
136
- return executionRouterRulesPath();
137
- }
47
+ import { classifyFailure, errorMessage, observability } from './observability.js';
48
+ import { createWorkspaceControl } from './relayd-control.js';
49
+ import { createDashboardActions } from './relayd-dashboard-actions.js';
50
+ export { daemonHelp, normalizeDaemonArguments, overlayOperationalOverviews, mergeOperationalInventory, recoveryScanRepositories, shouldCoalescePullRequestRefresh, SCHEDULE_HEARTBEAT_MS, dueScheduledOverviews, configureExecutionRouterRulesPath } from './relayd-utilities.js';
51
+ import { configureDaemonObservability, firstStageChoices, value, daemonHelp, normalizeDaemonArguments, SCHEDULE_HEARTBEAT_MS, dueScheduledOverviews, configureExecutionRouterRulesPath } from './relayd-utilities.js';
138
52
  export async function runDaemon(argv) {
139
53
  if (argv.includes('--help') || argv.includes('-h')) {
140
54
  console.log(daemonHelp);
@@ -156,13 +70,31 @@ export async function runDaemon(argv) {
156
70
  await startService([...argv]);
157
71
  return;
158
72
  }
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();
73
+ // Local authority never depends on GitHub availability, but every enabled
74
+ // GitHub transport must use the same authenticated context as the CLI. Probe
75
+ // identity separately so an auth outage is observable without blocking startup.
76
+ const auth = noGitHub ? null : await githubAuthContext(argv, undefined, false);
77
+ const client = noGitHub ? new DisabledGitHubClient() : auth.client;
78
+ const trusted = noGitHub ? new Set() : auth.trustedAuthors;
79
+ let githubAuthenticated = noGitHub ? false : null;
80
+ let githubAuthError = null;
81
+ if (!noGitHub) {
82
+ try {
83
+ const login = auth?.appAuth ? await client.currentLogin() : await client.localLogin();
84
+ if (!login)
85
+ throw new Error('gh authentication did not return a healthy login');
86
+ trusted.add(login);
87
+ githubAuthenticated = true;
88
+ }
89
+ catch (error) {
90
+ githubAuthenticated = false;
91
+ githubAuthError = errorMessage(error);
92
+ }
93
+ }
163
94
  const concurrency = Number(value(argv, '--concurrency', '4'));
164
95
  const eventResolution = await resolveEventBus([...argv]);
165
96
  const bus = eventResolution.bus;
97
+ const stopRuntimeStatusEvents = bus ? subscribeLocalCommits(createRuntimeStatusPublisher(bus)) : () => { };
166
98
  process.stderr.write(`${eventBusResolutionMessage(eventResolution)}\n`);
167
99
  const workspaceRoot = noGitHub ? (value(argv, '--project-path') || value(argv, '--workspace') || process.cwd())
168
100
  : normalized.workspaceRoot ?? defaultWorkspaceRoot();
@@ -184,7 +116,7 @@ export async function runDaemon(argv) {
184
116
  const key = `${repository}#${prNumber}`;
185
117
  let relay = relayStores.get(key);
186
118
  if (!relay) {
187
- relay = new SQLiteRelayStore(relayStorePath(join(homedir(), '.agents-relay', 'stores'), repository, prNumber));
119
+ relay = new SQLiteRelayStore(relayStorePath(relayStoreDirectory(), repository, prNumber));
188
120
  relayStores.set(key, relay);
189
121
  }
190
122
  const github = new GitHubStore(client, repository, prNumber, trusted, seed);
@@ -195,7 +127,7 @@ export async function runDaemon(argv) {
195
127
  return projected;
196
128
  };
197
129
  // SQLite inventory, not GitHub discovery, defines which repositories exist.
198
- const existingInventory = await discoverLocalRelayStoreSnapshots(join(homedir(), '.agents-relay', 'stores'));
130
+ const existingInventory = await discoverLocalRelayStoreSnapshots(relayStoreDirectory());
199
131
  for (const snapshot of existingInventory)
200
132
  for (const job of snapshot.jobs) {
201
133
  if (!repositories.includes(job.repository))
@@ -204,7 +136,7 @@ export async function runDaemon(argv) {
204
136
  if (repositories.length === 0)
205
137
  repositories.push('local/' + basename(workspaceRoot));
206
138
  const inventoryFromLocal = async () => {
207
- const snapshots = await discoverLocalRelayStoreSnapshots(join(homedir(), '.agents-relay', 'stores'));
139
+ const snapshots = await discoverLocalRelayStoreSnapshots(relayStoreDirectory());
208
140
  const known = new Set(repositories);
209
141
  const entries = snapshots.flatMap(snapshot => snapshot.jobs.filter(job => known.has(job.repository)).map(job => ({
210
142
  job, githubState: snapshot.facts?.state ?? 'UNKNOWN',
@@ -219,7 +151,16 @@ export async function runDaemon(argv) {
219
151
  return dashboardManagedJobs(entries.filter(item => !['COMPLETED', 'CANCELLED'].includes(item.job.state) || counts.get(item.job.id) !== 1));
220
152
  };
221
153
  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) });
154
+ 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) => {
155
+ const canonical = repositoryPaths.get(job.repository) ?? await resolveManagedProjectPath(job.repository, explicitProjectPath, workspaceRoot);
156
+ if (job.prNumber !== 0 || job.schedule)
157
+ return resolveRepositoryProjectPath(canonical, job.projectPath);
158
+ const sourceRef = await ensureLocalJobSourceBranch(canonical, job);
159
+ if (!store.mutateJob)
160
+ throw new Error('Local Job branch binding requires atomic SQLite mutation');
161
+ const bound = await store.mutateJob(job.id, current => current.localMerge ? current : ({ ...current, localMerge: { sourceRef, targetRef: 'refs/heads/main', status: 'pending' }, updatedAt: new Date().toISOString() }));
162
+ return prepareLocalJobExecutionWorktree(canonical, bound, workspaceRoot);
163
+ } });
223
164
  const refreshAll = async (_full = false) => {
224
165
  cachedOverviews = await inventoryFromLocal();
225
166
  return cachedOverviews;
@@ -233,112 +174,179 @@ export async function runDaemon(argv) {
233
174
  };
234
175
  };
235
176
  const refreshPullRequest = async (repository, prNumber, trigger, options) => (await refreshPullRequestSnapshot(repository, prNumber, trigger, options)).overviews;
236
- const localJobBinding = createJobIdRegistryResolver(join(homedir(), '.agents-relay', 'stores'), relayStores);
177
+ const localJobBinding = createJobIdRegistryResolver(relayStoreDirectory(), relayStores, { client, trustedAuthors: trusted, noGitHub });
237
178
  const requireLocalJobBinding = async (jobId) => {
238
179
  const binding = await localJobBinding(jobId);
239
180
  if (!binding)
240
181
  throw new Error('Local Job ' + jobId + ' not found or ambiguous in SQLite registry');
241
182
  return binding;
242
183
  };
243
- const pool = new RepositoryWorkerPool(concurrency, makeReconciler, inventoryFromLocal, localJobBinding);
244
- const initial = cachedOverviews;
245
- const fallback = initial[0]?.job;
246
- const dashboardStore = fallback ? (await requireLocalJobBinding(fallback.id)).store : new InMemoryStore();
247
- const dashboardOverview = (jobId, target) => cachedOverviews.find(item => item.job.id === jobId
248
- && (!target?.repository || item.job.repository === target.repository)
249
- && (!target?.prNumber || item.job.prNumber === target.prNumber));
250
- const workspaceJobAction = (action) => async (jobId) => {
251
- const overview = cachedOverviews.find(item => item.job.id === jobId);
252
- if (!overview)
253
- throw new Error(`Managed job ${jobId} not found`);
254
- const store = (await requireLocalJobBinding(jobId)).store;
255
- if (action === 'reconcile') {
256
- await pool.reconcileLocalJob(jobId, 'control.job.reconcile');
257
- return await store.load(jobId);
184
+ const gitText = async (cwd, args) => await new Promise((resolve, reject) => {
185
+ const child = spawnOwnedProcess('git', ['-C', cwd, ...args], { invocation: 'workspace.git', timeoutMs: 120000, spawn: { stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, GIT_TERMINAL_PROMPT: '0' } } });
186
+ let stdout = '';
187
+ let stderr = '';
188
+ child.stdout?.on('data', chunk => { stdout += chunk.toString(); });
189
+ child.stderr?.on('data', chunk => { stderr += chunk.toString(); });
190
+ child.once('error', reject);
191
+ child.once('close', code => code === 0 ? resolve(stdout.trim()) : reject(new Error(stderr.trim() || `git exited ${code}`)));
192
+ });
193
+ const refreshGitHubAuthentication = async () => {
194
+ if (noGitHub)
195
+ return;
196
+ try {
197
+ const login = auth?.appAuth ? await client.currentLogin() : await client.localLogin();
198
+ if (!login)
199
+ throw new Error('gh authentication did not return a healthy login');
200
+ trusted.add(login);
201
+ githubAuthenticated = true;
202
+ githubAuthError = null;
258
203
  }
259
- return await dashboardJobAction(store, makeReconciler(store, concurrency), action)(jobId);
260
- };
261
- const workspaceJobSetState = async (jobId, state, target) => {
262
- const overview = dashboardOverview(jobId, target);
263
- if (!overview)
264
- throw new Error(`Managed job ${jobId} not found`);
265
- const store = (await requireLocalJobBinding(jobId)).store;
266
- const result = await dashboardJobSetState(store)(jobId, state);
267
- await refreshAll();
268
- return result;
269
- };
270
- const workspaceTaskCreate = async (jobId, task) => {
271
- const overview = cachedOverviews.find(item => item.job.id === jobId);
272
- if (!overview)
273
- throw new Error(`Managed job ${jobId} not found`);
274
- if (task.jobId !== jobId)
275
- throw new Error(`Task ${task.id} belongs to ${task.jobId}, not ${jobId}`);
276
- const store = (await requireLocalJobBinding(jobId)).store;
277
- const job = await store.load(jobId);
278
- if (['COMPLETED', 'CANCELLED'].includes(job.state))
279
- throw new Error(`Cannot create task on terminal job ${job.id} (${job.state})`);
280
- await store.appendTask(task);
281
- const refreshed = await store.load(jobId);
282
- if (refreshed.state === 'DRAFT') {
283
- await applyJobActivateAction(refreshed);
284
- await store.saveJob(refreshed);
204
+ catch (error) {
205
+ githubAuthenticated = false;
206
+ githubAuthError = errorMessage(error);
285
207
  }
286
- await pool.reconcileLocalJob(jobId, 'control.task.create');
287
- return await store.load(jobId);
288
208
  };
289
- const workspaceTaskStart = async (jobId, taskId) => {
290
- const overview = cachedOverviews.find(item => item.job.id === jobId);
291
- if (!overview)
292
- throw new Error(`Managed job ${jobId} not found`);
293
- const store = (await requireLocalJobBinding(jobId)).store;
294
- const job = await store.load(jobId);
295
- const task = job.tasks.find(item => item.id === taskId);
296
- if (!task)
297
- throw new Error(`Task ${taskId} not found`);
298
- await applyOrchestratorStartAction(task);
299
- await store.saveTask(task);
300
- await publishOrchestratorTaskStarted(bus, job, task);
301
- await pool.reconcileLocalJob(jobId, 'control.task.start');
302
- return await store.load(jobId);
303
- };
304
- const workspaceTaskComplete = async (jobId, taskId, summary) => {
305
- const overview = cachedOverviews.find(item => item.job.id === jobId);
306
- if (!overview)
307
- throw new Error(`Managed job ${jobId} not found`);
308
- const store = (await requireLocalJobBinding(jobId)).store;
309
- const job = await store.load(jobId);
310
- const task = job.tasks.find(item => item.id === taskId);
311
- if (!task)
312
- throw new Error(`Task ${taskId} not found`);
313
- await applyOrchestratorCompleteAction(task, summary);
314
- await store.saveTask(task);
315
- await publishOrchestratorTaskCompletion(bus, job, task, summary);
316
- await pool.reconcileLocalJob(jobId, 'control.task.complete');
317
- return await store.load(jobId);
318
- };
319
- const workspaceTaskAction = (action) => async (jobId, taskId) => {
320
- const overview = cachedOverviews.find(item => item.job.id === jobId);
321
- if (!overview)
322
- throw new Error(`Managed job ${jobId} not found`);
323
- const store = (await requireLocalJobBinding(jobId)).store;
324
- return await dashboardTaskAction(store, makeReconciler(store, concurrency), action)(jobId, taskId);
209
+ const syncGitHubBindings = async () => {
210
+ if (noGitHub)
211
+ return;
212
+ await refreshGitHubAuthentication();
213
+ if (githubAuthenticated !== true)
214
+ return;
215
+ if (client.rateLimitStatus().limited)
216
+ return;
217
+ const snapshots = await discoverLocalRelayStoreSnapshots(relayStoreDirectory());
218
+ const bindingCandidates = orderGitHubBindingCandidates(snapshots.flatMap(snapshot => snapshot.jobs.map(job => ({ snapshotPath: snapshot.path, job }))));
219
+ for (const candidate of bindingCandidates) {
220
+ const snapshot = { path: candidate.snapshotPath };
221
+ const discovered = candidate.job;
222
+ try {
223
+ if (!repositoryPaths.has(discovered.repository) || !discovered.localMerge?.sourceRef || !discovered.localMerge?.targetRef)
224
+ continue;
225
+ const sourceRef = discovered.localMerge.sourceRef;
226
+ const targetRef = discovered.localMerge.targetRef;
227
+ if (!sourceRef.startsWith('refs/heads/') || !targetRef.startsWith('refs/heads/'))
228
+ continue;
229
+ if (targetRef !== 'refs/heads/main')
230
+ throw new Error('GitHub sync only supports local main as merge target');
231
+ const branch = sourceRef.slice('refs/heads/'.length), base = targetRef.slice('refs/heads/'.length);
232
+ let relay = relayStores.get(snapshot.path);
233
+ if (!relay) {
234
+ relay = new SQLiteRelayStore(snapshot.path);
235
+ relayStores.set(snapshot.path, relay);
236
+ }
237
+ const canonical = repositoryPaths.get(discovered.repository) ?? await resolveManagedProjectPath(discovered.repository, explicitProjectPath, workspaceRoot);
238
+ // A completed local Job may never have had a display PR. Publish main
239
+ // regardless: GitHub availability cannot determine SQLite completion.
240
+ if (discovered.state === 'COMPLETED' && discovered.localMerge?.status === 'succeeded'
241
+ && (discovered.externalPrNumber ?? discovered.prNumber) <= 0) {
242
+ const published = projectAuthoritativeLocalMerge(canonical, discovered);
243
+ if (published.status === 'projected')
244
+ await bus?.publish(eventFor(discovered.id, 'projection', null, 'projection.delivered', 'succeeded', `GitHub main synchronization ${published.status}`, 'orchestrator', { operation: 'github.syncMain', deliveryStatus: published.status, mergedCommitSha: published.localMergedCommitSha }, { agent: 'relay', component: 'github-projection' }));
245
+ continue;
246
+ }
247
+ const existingExternalPr = discovered.externalPrNumber ?? (discovered.prNumber > 0 ? discovered.prNumber : 0);
248
+ const terminal = ['COMPLETED', 'CANCELLED'].includes(discovered.state);
249
+ if (terminal && existingExternalPr <= 0)
250
+ continue;
251
+ const sourceSha = await gitText(canonical, ['rev-parse', '--verify', sourceRef]);
252
+ const facts = await relay.loadPullRequestFacts();
253
+ if (facts?.head?.sha !== sourceSha) {
254
+ await gitText(canonical, ['push', 'origin', `${branch}:${branch}`]);
255
+ }
256
+ let job = (await relay.loadJob(discovered.id)) ?? discovered;
257
+ let externalPrNumber = job.externalPrNumber ?? (job.prNumber > 0 ? job.prNumber : 0);
258
+ if (externalPrNumber === 0 && ['COMPLETED', 'CANCELLED'].includes(job.state))
259
+ continue;
260
+ if (externalPrNumber === 0) {
261
+ // Do not bootstrap an empty remote-only PR commit: it would diverge from
262
+ // the real local Job branch before its first Worker commit. Publish the
263
+ // review-only PR when there is an actual diff against main.
264
+ const ahead = Number(await gitText(canonical, ['rev-list', '--count', `${targetRef}..${sourceRef}`]));
265
+ if (!Number.isFinite(ahead) || ahead <= 0)
266
+ continue;
267
+ // GitHub compares origin/main, not local main. Do not invoke the legacy
268
+ // empty-PR bootstrap path when the published base already contains the
269
+ // source head (for example when main was pushed by another Job).
270
+ await gitText(canonical, ['fetch', '--no-tags', 'origin', `+refs/heads/main:refs/remotes/origin/main`]);
271
+ const remoteAhead = Number(await gitText(canonical, ['rev-list', '--count', `refs/remotes/origin/main..${sourceRef}`]));
272
+ if (!Number.isFinite(remoteAhead) || remoteAhead <= 0)
273
+ continue;
274
+ const pr = await resolvePullRequest(client, job.repository, { head: branch, base, title: job.title, body: job.objective ?? job.description ?? '' });
275
+ if (!relay.mutateJob)
276
+ throw new Error('Atomic SQLite mutation is required for GitHub PR binding');
277
+ job = await relay.mutateJob(job.id, current => ({ ...current, externalPrNumber: pr.number, updatedAt: new Date().toISOString() }));
278
+ externalPrNumber = pr.number;
279
+ observability().record({ stage: 'projection', event: 'github.pr_bound', outcome: 'succeeded', message: `Bound local Job ${job.id} to GitHub PR #${pr.number}`, correlation: { repository: job.repository, prNumber: pr.number, jobId: job.id }, data: { head: branch, base } });
280
+ }
281
+ if (externalPrNumber <= 0)
282
+ continue;
283
+ const latestFacts = await relay.loadPullRequestFacts();
284
+ if (latestFacts?.state !== 'MERGED' && (latestFacts?.head?.sha !== sourceSha || latestFacts?.head?.ref !== branch)) {
285
+ await relay.savePullRequestFacts({ state: latestFacts?.state === 'CLOSED' ? 'CLOSED' : 'OPEN', head: { ref: branch, sha: sourceSha }, observedAt: new Date().toISOString() });
286
+ }
287
+ const github = new GitHubStore(client, job.repository, externalPrNumber, trusted);
288
+ projectionWriters.set(`job:${job.id}`, new GitHubProjectionWriter(relay, github, undefined, true));
289
+ if (job.state === 'COMPLETED' && job.localMerge?.status === 'succeeded' && job.localMerge.projectionStrategy === 'target_ref_push') {
290
+ const injection = await consumeMethodInjection(relay, job.repository, job.id, 'github.projectLocalMerge');
291
+ const projectedMerge = await withTestInjection('github.projectLocalMerge', injection, () => projectAuthoritativeLocalMerge(canonical, job));
292
+ await bus?.publish(eventFor(job.id, 'projection', null, 'projection.delivered', 'succeeded', `GitHub local merge projection ${projectedMerge.status}`, 'orchestrator', { operation: 'github.projectLocalMerge', deliveryStatus: projectedMerge.status, mergedCommitSha: projectedMerge.localMergedCommitSha }, { agent: 'relay', component: 'github-projection' }));
293
+ // A review-only PR is never merged by GitHub. Close it after local main
294
+ // has been safely published, retaining the diff, comments and commit link.
295
+ const remotePr = await github.pullRequestState();
296
+ await archiveDisplayPullRequest(client, job.repository, externalPrNumber, job.id, projectedMerge.projectedCommitSha, remotePr);
297
+ const projectedState = await github.pullRequestState();
298
+ const projectedHead = await github.pullRequestHead();
299
+ await relay.savePullRequestFacts({ state: projectedState, head: projectedHead, observedAt: new Date().toISOString() });
300
+ observability().record({
301
+ stage: 'projection', event: 'github.local_merge_projected', outcome: 'succeeded',
302
+ message: `Projected authoritative local merge ${projectedMerge.localMergedCommitSha} to ${projectedMerge.targetRef}`,
303
+ correlation: { repository: job.repository, prNumber: externalPrNumber, jobId: job.id },
304
+ data: { ...projectedMerge, githubState: projectedState },
305
+ });
306
+ }
307
+ }
308
+ catch (error) {
309
+ await bus?.publish(eventFor(discovered.id, 'projection', null, 'projection.delivery_failed', 'failed', `GitHub projection failed: ${errorMessage(error)}`, 'orchestrator', { operation: 'github.projectLocalMerge', error: errorMessage(error) }, { agent: 'relay', component: 'github-projection' }))?.catch(() => { });
310
+ observability().record({
311
+ stage: 'projection', event: 'github.binding_job_failed', level: 'warn', outcome: 'failed',
312
+ message: `GitHub binding/projection failed for ${discovered.id}: ${errorMessage(error)}`,
313
+ correlation: { repository: discovered.repository, prNumber: discovered.externalPrNumber ?? discovered.prNumber, jobId: discovered.id },
314
+ data: { classification: classifyFailure(error).classification, reason: errorMessage(error) },
315
+ });
316
+ }
317
+ }
318
+ cachedOverviews = await inventoryFromLocal();
325
319
  };
326
- const workspaceTaskSetState = async (jobId, taskId, state, target) => {
327
- const overview = dashboardOverview(jobId, target);
328
- if (!overview)
329
- throw new Error(`Managed job ${jobId} not found`);
330
- const store = (await requireLocalJobBinding(jobId)).store;
331
- const result = await dashboardTaskSetState(store)(jobId, taskId, state);
332
- await refreshAll();
333
- return result;
320
+ let githubBindingSyncInFlight = null;
321
+ const syncGitHubBindingsSafely = async () => {
322
+ if (githubBindingSyncInFlight)
323
+ return githubBindingSyncInFlight;
324
+ githubBindingSyncInFlight = (async () => {
325
+ try {
326
+ await syncGitHubBindings();
327
+ }
328
+ catch (error) {
329
+ observability().record({ stage: 'projection', event: 'github.binding_sync_failed', level: 'warn', outcome: 'failed', message: `GitHub Job binding sync failed: ${errorMessage(error)}`, data: { classification: classifyFailure(error).classification, reason: errorMessage(error) } });
330
+ }
331
+ finally {
332
+ githubBindingSyncInFlight = null;
333
+ }
334
+ })();
335
+ return githubBindingSyncInFlight;
334
336
  };
337
+ const pool = new RepositoryWorkerPool(concurrency, makeReconciler, inventoryFromLocal, localJobBinding);
338
+ const initial = cachedOverviews;
339
+ const fallback = initial[0]?.job;
340
+ const dashboardStore = fallback ? (await requireLocalJobBinding(fallback.id)).store : new InMemoryStore();
341
+ const { dashboardOverview, workspaceJobAction, workspaceJobSetState, workspaceTaskCreate, workspaceTaskStart, workspaceTaskComplete, workspaceTaskAction, workspaceTaskSetState } = createDashboardActions({ cachedOverviews, requireLocalJobBinding, pool, dashboardJobAction, makeReconciler, concurrency, dashboardJobSetState, refreshAll, applyJobActivateAction, applyOrchestratorStartAction, applyOrchestratorCompleteAction, publishOrchestratorTaskStarted, publishOrchestratorTaskCompletion, bus, dashboardTaskAction, dashboardTaskSetState });
342
+ const workspaceControl = createWorkspaceControl({ client, trusted, noGitHub, repositories, refreshAll, requireLocalJobBinding, makeReconciler, concurrency, pool, bus, isEffectivelyBlocked, unblockAutonomousTasks, unblockTask, publishTaskUnblocked, dispatchTaskAction, registerBlockerDependency, discoverAgents, ensureManagedGitHubJob });
335
343
  const secretFile = value(argv, '--github-webhook-secret-file');
336
344
  const secret = noGitHub ? '' : value(argv, '--github-webhook-secret') || process.env.AGENTS_RELAY_GITHUB_WEBHOOK_SECRET || (secretFile ? (await readFile(secretFile, 'utf8')).trim() : '');
337
345
  const webhook = secret ? {
338
346
  secret,
339
347
  repositories: new Set(repositories),
340
348
  wake: async (event) => {
341
- const targets = cachedOverviews.filter(item => item.job.repository === event.repository && item.job.prNumber === event.pullRequest);
349
+ const targets = cachedOverviews.filter(item => item.job.repository === event.repository && (event.jobId ? item.job.id === event.jobId : (item.job.externalPrNumber ?? item.job.prNumber) === event.pullRequest));
342
350
  if (targets.length === 0)
343
351
  return;
344
352
  for (const target of targets) {
@@ -386,7 +394,8 @@ export async function runDaemon(argv) {
386
394
  }
387
395
  };
388
396
  const stopWatch = bus ? await pool.watch(bus) : async () => { };
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, {
397
+ 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, () => ({ githubEnabled: !noGitHub, githubAuthenticated, githubAuthError, ...client.rateLimitStatus(), readPaths: { ...githubReadPaths } }), noGitHub ? undefined : () => client.rateLimitSnapshot(), exactJob, {
398
+ control: workspaceControl,
390
399
  start: workspaceJobAction('start'),
391
400
  reconcile: workspaceJobAction('reconcile'),
392
401
  cancel: workspaceJobAction('cancel'),
@@ -502,20 +511,21 @@ export async function runDaemon(argv) {
502
511
  let interval;
503
512
  let scheduleInterval;
504
513
  let projectionInterval;
505
- const stop = () => { if (interval)
514
+ const stop = () => { stopRuntimeStatusEvents(); if (interval)
506
515
  clearInterval(interval); if (scheduleInterval)
507
516
  clearInterval(scheduleInterval); if (projectionInterval)
508
517
  clearInterval(projectionInterval); void Promise.allSettled([stopWatch(), shutdownOwnedProcesses('parent-shutdown')]).finally(() => { for (const store of relayStores.values())
509
518
  store.close(); server.close(() => process.exit(0)); }); };
510
519
  process.once('SIGINT', stop);
511
520
  process.once('SIGTERM', stop);
521
+ await syncGitHubBindingsSafely();
512
522
  await pool.reconcileKnown(cachedOverviews);
513
523
  await scheduleHeartbeatSafely();
514
524
  await flushProjectionsSafely();
515
525
  interval = setInterval(() => void reconcileSafely().finally(flushProjectionsSafely), Number(value(argv, '--interval', String(watchdogDefault))));
516
526
  interval.unref();
517
527
  if (!noGitHub) {
518
- projectionInterval = setInterval(() => void flushProjectionsSafely(), Number(value(argv, '--projection-ms', String(SERVICE_DEFAULTS.projectionFlushMs))));
528
+ projectionInterval = setInterval(() => void syncGitHubBindingsSafely().then(flushProjectionsSafely), Number(value(argv, '--projection-ms', String(SERVICE_DEFAULTS.projectionFlushMs))));
519
529
  projectionInterval.unref();
520
530
  }
521
531
  scheduleInterval = setInterval(() => void scheduleHeartbeatSafely(), SCHEDULE_HEARTBEAT_MS);
@@ -0,0 +1,12 @@
1
+ /** Called inside SQLite mutateJob against the latest durable Job snapshot. */
2
+ export function reviewGenerationMatches(current, original, task, headSha) {
3
+ const fresh = current.tasks.find(item => item.id === task.id);
4
+ if (!fresh || fresh.reviewHeadSha !== headSha || current.state !== 'ACTIVE'
5
+ || (current.headSha && current.headSha !== headSha)
6
+ || fresh.executionId !== task.executionId)
7
+ return false;
8
+ if (current.review)
9
+ return current.review.taskId === task.id && current.review.headSha === headSha
10
+ && current.review.outcome === (original.review?.outcome ?? 'pending');
11
+ return Boolean(task.planningDecisionId && !original.review?.taskId);
12
+ }
@@ -0,0 +1,21 @@
1
+ import { deriveJobRuntimeStatus } from './runtime-status.js';
2
+ import { eventFor } from './events.js';
3
+ /** Process-local change detector. No extra durable status field or polling required. */
4
+ export function createRuntimeStatusPublisher(bus) {
5
+ const last = new Map();
6
+ return (job) => {
7
+ const key = `${job.repository}\0${job.id}`;
8
+ const to = deriveJobRuntimeStatus(job);
9
+ const from = last.get(key);
10
+ if (from === to)
11
+ return;
12
+ last.set(key, to);
13
+ // First observation establishes a baseline rather than fabricating a transition.
14
+ if (from === undefined)
15
+ return;
16
+ const event = eventFor(job.id, '', null, 'job.runtime_status.changed', 'info', `${from} → ${to}`, 'orchestrator', { from, to, repository: job.repository, jobState: job.state, updatedAt: job.updatedAt }, { agent: 'relay', component: 'runtime-status' });
17
+ void bus.publish(event).catch(error => {
18
+ process.stderr.write(`runtime status change publish failed ${job.id}: ${String(error)}\n`);
19
+ });
20
+ };
21
+ }
@@ -20,6 +20,11 @@ export function deriveJobRuntimeStatus(job, _prState = 'OPEN') {
20
20
  return 'COMPLETED';
21
21
  if (job.state === 'DRAFT')
22
22
  return 'IDLE';
23
+ const currentPlan = job.plans?.at(-1);
24
+ // An unresolved planner failure is a Job failure, not an idle Job.
25
+ // Active task execution still takes precedence below.
26
+ const plannerFailed = currentPlan?.status === 'FAILED' && !job.tasks.some(task => ['QUEUED', 'RUNNING'].includes(task.state));
27
+ const plannerRunning = currentPlan?.status === 'PLANNING';
23
28
  if (job.schedule) {
24
29
  if (job.tasks.some(task => task.state === 'RUNNING'))
25
30
  return 'RUNNING';
@@ -31,6 +36,10 @@ export function deriveJobRuntimeStatus(job, _prState = 'OPEN') {
31
36
  return 'FAILED';
32
37
  if (job.tasks.some(task => task.state === 'DRAFT'))
33
38
  return 'WAITING';
39
+ if (plannerFailed)
40
+ return 'FAILED';
41
+ if (plannerRunning)
42
+ return 'RUNNING';
34
43
  return 'WAITING';
35
44
  }
36
45
  // Draft or queued reviewers must not mask implementation that is still active.
@@ -54,6 +63,10 @@ export function deriveJobRuntimeStatus(job, _prState = 'OPEN') {
54
63
  return 'FAILED';
55
64
  if (job.tasks.some(task => task.state === 'DRAFT'))
56
65
  return 'WAITING';
66
+ if (plannerFailed)
67
+ return 'FAILED';
68
+ if (plannerRunning)
69
+ return 'RUNNING';
57
70
  if (job.executionMode === 'autonomous' && job.autoMerge === false && job.reviewEnabled === false && implementationComplete(job))
58
71
  return 'WAITING';
59
72
  if (job.reviewEnabled === false && implementationComplete(job))