agents-relay 2.0.67 → 2.0.69

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 (72) hide show
  1. package/README.md +56 -20
  2. package/dist/adapters.js +39 -3
  3. package/dist/blockers.js +17 -4
  4. package/dist/cli-blocker.js +12 -30
  5. package/dist/cli-help.js +22 -17
  6. package/dist/cli-job-create.js +77 -0
  7. package/dist/cli-local-merge-config.js +11 -0
  8. package/dist/cli-projection.js +23 -55
  9. package/dist/cli-review.js +7 -24
  10. package/dist/cli-support.js +81 -8
  11. package/dist/cli-worker-task.js +63 -0
  12. package/dist/cli.js +223 -185
  13. package/dist/dashboard-html.js +15 -12
  14. package/dist/dashboard.js +27 -2
  15. package/dist/e2e-injection.js +76 -0
  16. package/dist/github-auth.js +4 -3
  17. package/dist/github-binding-order.js +15 -0
  18. package/dist/github-client.js +7 -2
  19. package/dist/github-pr-archive.js +22 -0
  20. package/dist/github-webhook.js +43 -10
  21. package/dist/job-actions.js +29 -0
  22. package/dist/local-authority-store.js +288 -0
  23. package/dist/local-commit-events.js +2 -0
  24. package/dist/local-git-merge.js +124 -0
  25. package/dist/local-job-path.js +7 -0
  26. package/dist/local-job-registry.js +29 -0
  27. package/dist/local-managed-store.js +21 -0
  28. package/dist/local-merge-delivery.js +53 -0
  29. package/dist/local-merge-lock.js +103 -0
  30. package/dist/local-merge-projection.js +60 -0
  31. package/dist/managed-worker-input.js +13 -6
  32. package/dist/manual-plan.js +29 -7
  33. package/dist/no-github.js +11 -0
  34. package/dist/planned-review-head.js +7 -8
  35. package/dist/planner-runtime.js +182 -41
  36. package/dist/planner.js +35 -2
  37. package/dist/pool.js +89 -136
  38. package/dist/projected-store.js +20 -3
  39. package/dist/projection-outbox.js +3 -1
  40. package/dist/publication-store.js +37 -0
  41. package/dist/reconciler-cancellation.js +21 -0
  42. package/dist/reconciler-review.js +99 -29
  43. package/dist/reconciler-runtime.js +72 -35
  44. package/dist/reconciler.js +124 -85
  45. package/dist/relay-store.js +108 -6
  46. package/dist/relayd-control.js +184 -0
  47. package/dist/relayd-dashboard-actions.js +108 -0
  48. package/dist/relayd-utilities.js +107 -0
  49. package/dist/relayd.js +304 -299
  50. package/dist/review-generation-fence.js +12 -0
  51. package/dist/runtime-status-events.js +21 -0
  52. package/dist/runtime-status.js +21 -7
  53. package/dist/scheduled-jobs.js +43 -36
  54. package/dist/service-external-actions.js +27 -0
  55. package/dist/service-publication.js +29 -0
  56. package/dist/store-github.js +17 -6
  57. package/dist/task-actions.js +59 -1
  58. package/dist/task-update.js +1 -1
  59. package/dist/troubleshooter.js +21 -2
  60. package/dist/unblock.js +47 -15
  61. package/dist/worker-rejection.js +11 -0
  62. package/dist/workflow/contracts.js +10 -1
  63. package/dist/workflow/definitions-v2.js +2 -0
  64. package/dist/workflow/job-reconcile-v2.js +9 -60
  65. package/dist/workflow/runtime.js +18 -3
  66. package/dist/workflow-effect-failures.js +30 -0
  67. package/dist/workspace.js +95 -6
  68. package/package.json +6 -3
  69. package/skills/agents-relay/agents/planner.agent.md +3 -0
  70. package/skills/agents-relay/contracts/orchestrator-workflows.md +4 -7
  71. package/workflow/job-lifecycle.mmd +19 -60
  72. package/workflow/review-lifecycle.mmd +5 -8
package/dist/relayd.js CHANGED
@@ -1,137 +1,54 @@
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';
6
8
  import { ChatGptAdapter, CodexAdapter, ensureChatGptBrowserWorkspace } from './adapters.js';
7
9
  import { CodexThreadContinuation, CommandContinuation, WebhookContinuation } from './continuation.js';
8
10
  import { serveDashboard } from './dashboard.js';
9
- import { RepositoryWorkerPool, aggregateManagedGitHubJobs } from './pool.js';
11
+ import { RepositoryWorkerPool } from './pool.js';
10
12
  import { Reconciler } from './reconciler.js';
11
- import { dashboardManagedJobs, GitHubStore, InMemoryStore, loadManagedGitHubPullRequestSnapshotGraphql, withGitHubRequestContext } from './store.js';
12
- import { ProjectedDurableStore } from './projected-store.js';
13
+ import { dashboardManagedJobs, GitHubStore, InMemoryStore, resolvePullRequest } from './store.js';
14
+ import { GitHubProjectionWriter } from './projected-store.js';
15
+ import { LocalAuthorityDurableStore } from './local-authority-store.js';
13
16
  import { discoverLocalRelayStoreSnapshots, relayStorePath, SQLiteRelayStore } from './relay-store.js';
17
+ import { createJobIdRegistryResolver } from './local-job-registry.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';
14
21
  import { githubAuthContext } from './github-auth.js';
22
+ import { DisabledGitHubClient, noGitHubMode } from './no-github.js';
23
+ import { basename } from 'node:path';
15
24
  import { dashboardJobAction, isEntrypoint, runtimePlanner, runtimeReviewer, startService, SERVICE_DEFAULTS } from './cli.js';
16
- import { eventBusResolutionMessage, publishOrchestratorTaskStarted, publishOrchestratorTaskCompletion, resolveEventBus } from './cli-support.js';
25
+ import { eventBusResolutionMessage, publishOrchestratorTaskStarted, publishOrchestratorTaskCompletion, publishTaskUnblocked, resolveEventBus } from './cli-support.js';
17
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';
18
31
  import { applyJobActivateAction } from './job-actions.js';
19
32
  import { applyOrchestratorStartAction, applyOrchestratorCompleteAction } from './task-actions.js';
20
33
  import { managedTaskUpdateHandler } from './task-update.js';
21
34
  import { dashboardJobSetState, dashboardTaskAction, dashboardTaskSetState } from './dashboard-actions.js';
22
35
  import { codexAndZaiUsageRegistry } from './usage.js';
23
- import { defaultWorkspaceRoot, discoverWorkspaceRepositories, resolveManagedProjectPath, resolveRepositoryProjectPath } from './workspace.js';
36
+ import { defaultWorkspaceRoot, discoverWorkspaceRepositories, ensureLocalJobSourceBranch, prepareLocalJobExecutionWorktree, resolveManagedProjectPath, resolveRepositoryProjectPath } from './workspace.js';
24
37
  import { deriveJobRuntimeStatus } from './runtime-status.js';
38
+ import { createRuntimeStatusPublisher } from './runtime-status-events.js';
39
+ import { subscribeLocalCommits } from './local-commit-events.js';
25
40
  import { decisionMode, executionDecisionEngine } from './decision.js';
26
41
  import { JsonlDecisionAuditStore } from './decision-audit.js';
27
42
  import { summarizeRuntime } from './runtime-summary.js';
28
- import { executionRouterRulesPath } from './routing-policy.js';
29
43
  import { workerCandidates } from './worker-routing.js';
44
+ import { discoverAgents } from './registry.js';
30
45
  import { shutdownOwnedProcesses } from './owned-process.js';
31
46
  import { routeExecution } from './execution-routing.js';
32
- import { RelayObservability, classifyFailure, errorMessage, observability, setObservability } from './observability.js';
33
- /** Initialize the process-wide structured observability sink once, from environment. */
34
- export function configureDaemonObservability(env = process.env) {
35
- const configured = RelayObservability.fromEnvironment(env);
36
- setObservability(configured);
37
- return configured;
38
- }
39
- export async function overlayOperationalOverviews(overviews, storeFor) {
40
- return await Promise.all(overviews.map(async (item) => {
41
- const store = storeFor(item.job.repository, item.job.prNumber);
42
- await store.hydrate?.(item.job);
43
- const job = await store.load(item.job.id);
44
- return { ...item, job };
45
- }));
46
- }
47
- function managedOverviewKey(item) {
48
- return item.job.repository + '#' + item.job.prNumber + ':' + item.job.id;
49
- }
50
- export function mergeOperationalInventory(current, discovered) {
51
- const merged = new Map(current.map(item => [managedOverviewKey(item), item]));
52
- for (const item of discovered)
53
- merged.set(managedOverviewKey(item), item);
54
- return [...merged.values()];
55
- }
56
- const PR_REFRESH_COALESCE_MS = 1000;
57
- export const SCHEDULE_HEARTBEAT_MS = 600_000;
58
- 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());
60
- }
61
- export function shouldCoalescePullRequestRefresh(completedAt, now = Date.now(), force = false) {
62
- return !force && completedAt !== null && now >= completedAt && now - completedAt < PR_REFRESH_COALESCE_MS;
63
- }
64
- export const daemonHelp = `Agents Relay daemon runs the dashboard and worker pool.
65
-
66
- Usage:
67
- npx agents-relayd [options]
68
-
69
- Options:
70
- --repo OWNER/REPO Watch one repository
71
- --workspace PATH Discover repositories below PATH (default: ~/Workspace)
72
- --port PORT Dashboard port (default: 8787)
73
- --concurrency N Maximum concurrent workers (default: 4)
74
- --interval MS Recovery interval
75
- --projection-ms MS GitHub projection flush interval (default: 1000)
76
- --execution-router-rules PATH
77
- Router Rules file (default: ~/.agents-relay/execution-router-rules.md)
78
- --events nats Require NATS event wakeups (otherwise auto-detect reachable NATS)
79
-
80
- Legacy single-job mode also accepts --repo OWNER/REPO --pr NUMBER --id JOB_ID.
81
-
82
- Examples:
83
- npx agents-relayd --repo OWNER/REPO
84
- npx agents-relayd --workspace ~/Workspace`;
85
- function firstStageChoices(decisionInput) {
86
- const stages = decisionInput?.stages;
87
- if (!Array.isArray(stages) || stages.length === 0)
88
- return [];
89
- const contract = stages[0].contract;
90
- return Array.isArray(contract?.choices) ? contract.choices.filter((item) => typeof item === 'string') : [];
91
- }
92
- function value(args, name, fallback = '') { const index = args.indexOf(name); return index >= 0 ? args[index + 1] ?? fallback : fallback; }
93
- export function normalizeDaemonArguments(argv) {
94
- const repository = value(argv, '--repo') || null;
95
- const workspaceRoot = value(argv, '--workspace', defaultWorkspaceRoot()) || null;
96
- const prValue = value(argv, '--pr');
97
- const id = value(argv, '--id');
98
- if ((prValue && !id) || (!prValue && id))
99
- throw new Error('--pr and --id must be supplied together for legacy single-job mode');
100
- if ((prValue || id) && !repository)
101
- throw new Error('--repo is required for legacy single-job mode');
102
- if (repository && argv.includes('--workspace'))
103
- throw new Error('--repo and --workspace are mutually exclusive');
104
- const pullRequest = prValue ? Number(prValue) : null;
105
- if (pullRequest !== null && (!Number.isInteger(pullRequest) || pullRequest <= 0))
106
- throw new Error('--pr must be positive');
107
- return { repository, workspaceRoot: repository ? null : workspaceRoot, pullRequest, jobId: id || null, serviceArguments: [...argv] };
108
- }
109
- export function recoveryScanRepositories(repositories, cached, dormantProbeIndex = 0) {
110
- const byRepository = new Map();
111
- for (const item of cached) {
112
- const values = byRepository.get(item.job.repository) ?? [];
113
- values.push(item);
114
- byRepository.set(item.job.repository, values);
115
- }
116
- const activeOrUnknown = [];
117
- const dormant = [];
118
- for (const repository of repositories) {
119
- const values = byRepository.get(repository);
120
- if (!values || values.length === 0 || values.some(item => item.githubState === 'OPEN' && !['COMPLETED', 'CANCELLED'].includes(item.job.state)))
121
- activeOrUnknown.push(repository);
122
- else
123
- dormant.push(repository);
124
- }
125
- if (dormant.length === 0)
126
- return activeOrUnknown;
127
- return [...activeOrUnknown, dormant[Math.abs(dormantProbeIndex) % dormant.length]];
128
- }
129
- export function configureExecutionRouterRulesPath(argv) {
130
- const explicitRulesPath = value(argv, '--execution-router-rules') || undefined;
131
- if (explicitRulesPath)
132
- process.env.AGENTS_RELAY_EXECUTION_ROUTER_RULES = executionRouterRulesPath(explicitRulesPath);
133
- return executionRouterRulesPath();
134
- }
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';
135
52
  export async function runDaemon(argv) {
136
53
  if (argv.includes('--help') || argv.includes('-h')) {
137
54
  console.log(daemonHelp);
@@ -145,22 +62,48 @@ export async function runDaemon(argv) {
145
62
  catch (error) {
146
63
  process.stderr.write(`Browser Workspace bootstrap skipped: ${errorMessage(error)}\n`);
147
64
  }
65
+ const noGitHub = noGitHubMode(argv);
66
+ if (noGitHub)
67
+ process.env.AGENTS_RELAY_NO_GITHUB = '1';
148
68
  const normalized = normalizeDaemonArguments(argv);
149
69
  if (normalized.pullRequest && normalized.jobId) {
150
70
  await startService([...argv]);
151
71
  return;
152
72
  }
153
- const auth = await githubAuthContext(argv);
154
- const client = auth.client;
155
- const trusted = auth.trustedAuthors;
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
+ }
156
94
  const concurrency = Number(value(argv, '--concurrency', '4'));
157
95
  const eventResolution = await resolveEventBus([...argv]);
158
96
  const bus = eventResolution.bus;
97
+ const stopRuntimeStatusEvents = bus ? subscribeLocalCommits(createRuntimeStatusPublisher(bus)) : () => { };
159
98
  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);
99
+ const workspaceRoot = noGitHub ? (value(argv, '--project-path') || value(argv, '--workspace') || process.cwd())
100
+ : normalized.workspaceRoot ?? defaultWorkspaceRoot();
101
+ const discoveredRepositories = normalized.repository || noGitHub ? [] : await discoverWorkspaceRepositories(workspaceRoot).catch(() => []);
102
+ const repositories = noGitHub && !normalized.repository ? [`local/${basename(workspaceRoot)}`] : normalized.repository ? [normalized.repository] : discoveredRepositories.map(item => item.repository);
163
103
  const repositoryPaths = new Map(discoveredRepositories.map(item => [item.repository, item.path]));
104
+ if (noGitHub)
105
+ for (const repo of repositories)
106
+ repositoryPaths.set(repo, value(argv, '--project-path') || workspaceRoot);
164
107
  const explicitProjectPath = value(argv, '--project-path');
165
108
  const usageRegistry = codexAndZaiUsageRegistry();
166
109
  const decisionAuditStore = new JsonlDecisionAuditStore(process.env.AGENTS_RELAY_DECISION_AUDIT_PATH ?? join(homedir(), '.agents-relay', 'decision-audit.jsonl'));
@@ -177,201 +120,255 @@ export async function runDaemon(argv) {
177
120
  relayStores.set(key, relay);
178
121
  }
179
122
  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);
123
+ const projected = new LocalAuthorityDurableStore(relay, github, (relatedRepository, relatedPrNumber) => runtimeStore(relatedRepository, relatedPrNumber), noGitHub);
124
+ // SQLite PR facts are written through awaited webhook/action handlers; never seed detached remote snapshots.
125
+ if (!noGitHub)
126
+ projectionWriters.set(key, projected.projection);
188
127
  return projected;
189
128
  };
129
+ // SQLite inventory, not GitHub discovery, defines which repositories exist.
130
+ const existingInventory = await discoverLocalRelayStoreSnapshots(join(homedir(), '.agents-relay', 'stores'));
131
+ for (const snapshot of existingInventory)
132
+ for (const job of snapshot.jobs) {
133
+ if (!repositories.includes(job.repository))
134
+ repositories.push(job.repository);
135
+ }
190
136
  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;
137
+ repositories.push('local/' + basename(workspaceRoot));
138
+ const inventoryFromLocal = async () => {
139
+ const snapshots = await discoverLocalRelayStoreSnapshots(join(homedir(), '.agents-relay', 'stores'));
140
+ const known = new Set(repositories);
141
+ const entries = snapshots.flatMap(snapshot => snapshot.jobs.filter(job => known.has(job.repository)).map(job => ({
142
+ job, githubState: snapshot.facts?.state ?? 'UNKNOWN',
143
+ draft: false, mergedAt: null, closedAt: null, publication: snapshot.publications[job.id]?.latest ?? null, publicationFailure: snapshot.publications[job.id]?.failure ?? null,
144
+ })));
145
+ const counts = new Map();
146
+ for (const item of entries)
147
+ if (!['COMPLETED', 'CANCELLED'].includes(item.job.state))
148
+ counts.set(item.job.id, (counts.get(item.job.id) ?? 0) + 1);
149
+ // Hide obsolete terminal snapshots only when one live authoritative Job exists.
150
+ // Multiple live copies remain visible for diagnosis but cannot be mutated.
151
+ return dashboardManagedJobs(entries.filter(item => !['COMPLETED', 'CANCELLED'].includes(item.job.state) || counts.get(item.job.id) !== 1));
152
+ };
153
+ cachedOverviews = await inventoryFromLocal();
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
+ } });
164
+ const refreshAll = async (_full = false) => {
165
+ cachedOverviews = await inventoryFromLocal();
166
+ return cachedOverviews;
167
+ };
168
+ const overviews = async (_forceRefresh = false, _trigger = 'internal.unknown') => refreshAll();
169
+ const refreshPullRequestSnapshot = async (repository, prNumber, _trigger = 'internal', _options = {}) => {
170
+ const inventory = await refreshAll();
171
+ return {
172
+ overviews: inventory.filter(item => item.job.repository === repository && item.job.prNumber === prNumber),
173
+ seed: { prNode: {}, comments: null },
174
+ };
175
+ };
176
+ const refreshPullRequest = async (repository, prNumber, trigger, options) => (await refreshPullRequestSnapshot(repository, prNumber, trigger, options)).overviews;
177
+ const localJobBinding = createJobIdRegistryResolver(join(homedir(), '.agents-relay', 'stores'), relayStores, { client, trustedAuthors: trusted, noGitHub });
178
+ const requireLocalJobBinding = async (jobId) => {
179
+ const binding = await localJobBinding(jobId);
180
+ if (!binding)
181
+ throw new Error('Local Job ' + jobId + ' not found or ambiguous in SQLite registry');
182
+ return binding;
183
+ };
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;
208
196
  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;
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;
214
203
  }
215
204
  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;
205
+ githubAuthenticated = false;
206
+ githubAuthError = errorMessage(error);
218
207
  }
219
208
  };
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));
224
- return cachedOverviews;
225
- };
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 = {}) => {
209
+ const syncGitHubBindings = async () => {
210
+ if (noGitHub)
211
+ return;
212
+ await refreshGitHubAuthentication();
213
+ if (githubAuthenticated !== true)
214
+ return;
230
215
  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;
216
+ return;
217
+ const snapshots = await discoverLocalRelayStoreSnapshots(join(homedir(), '.agents-relay', 'stores'));
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
+ }
245
317
  }
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;
318
+ cachedOverviews = await inventoryFromLocal();
319
+ };
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
+ }
256
334
  })();
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
- }
335
+ return githubBindingSyncInFlight;
265
336
  };
266
- 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));
337
+ const pool = new RepositoryWorkerPool(concurrency, makeReconciler, inventoryFromLocal, localJobBinding);
268
338
  const initial = cachedOverviews;
269
339
  const fallback = initial[0]?.job;
270
- const dashboardStore = fallback ? runtimeStore(fallback.repository, fallback.prNumber) : new InMemoryStore();
271
- const dashboardOverview = (jobId, target) => cachedOverviews.find(item => item.job.id === jobId
272
- && (!target?.repository || item.job.repository === target.repository)
273
- && (!target?.prNumber || item.job.prNumber === target.prNumber));
274
- const workspaceJobAction = (action) => async (jobId) => {
275
- const overview = cachedOverviews.find(item => item.job.id === jobId);
276
- if (!overview)
277
- throw new Error(`Managed job ${jobId} not found`);
278
- const store = runtimeStore(overview.job.repository, overview.job.prNumber);
279
- if (action === 'reconcile') {
280
- await pool.reconcileTarget(overview.job.repository, overview.job.prNumber, jobId);
281
- return await store.load(jobId);
282
- }
283
- return await dashboardJobAction(store, makeReconciler(store, concurrency), action)(jobId);
284
- };
285
- const workspaceJobSetState = async (jobId, state, target) => {
286
- const overview = dashboardOverview(jobId, target);
287
- if (!overview)
288
- throw new Error(`Managed job ${jobId} not found`);
289
- const store = runtimeStore(overview.job.repository, overview.job.prNumber);
290
- const result = await dashboardJobSetState(store)(jobId, state);
291
- await refreshPullRequest(overview.job.repository, overview.job.prNumber, 'dashboard.job-state', { force: true });
292
- return result;
293
- };
294
- const workspaceTaskCreate = async (jobId, task) => {
295
- const overview = cachedOverviews.find(item => item.job.id === jobId);
296
- if (!overview)
297
- throw new Error(`Managed job ${jobId} not found`);
298
- if (task.jobId !== jobId)
299
- throw new Error(`Task ${task.id} belongs to ${task.jobId}, not ${jobId}`);
300
- const store = runtimeStore(overview.job.repository, overview.job.prNumber);
301
- const job = await store.load(jobId);
302
- if (['COMPLETED', 'CANCELLED'].includes(job.state))
303
- throw new Error(`Cannot create task on terminal job ${job.id} (${job.state})`);
304
- await store.appendTask(task);
305
- const refreshed = await store.load(jobId);
306
- if (refreshed.state === 'DRAFT') {
307
- await applyJobActivateAction(refreshed);
308
- await store.saveJob(refreshed);
309
- }
310
- await pool.reconcileTarget(overview.job.repository, overview.job.prNumber, jobId, 'control.task.create');
311
- return await store.load(jobId);
312
- };
313
- const workspaceTaskStart = async (jobId, taskId) => {
314
- const overview = cachedOverviews.find(item => item.job.id === jobId);
315
- if (!overview)
316
- throw new Error(`Managed job ${jobId} not found`);
317
- const store = runtimeStore(overview.job.repository, overview.job.prNumber);
318
- const job = await store.load(jobId);
319
- const task = job.tasks.find(item => item.id === taskId);
320
- if (!task)
321
- throw new Error(`Task ${taskId} not found`);
322
- await applyOrchestratorStartAction(task);
323
- await store.saveTask(task);
324
- await publishOrchestratorTaskStarted(bus, job, task);
325
- await pool.reconcileTarget(overview.job.repository, overview.job.prNumber, jobId, 'control.task.start');
326
- return await store.load(jobId);
327
- };
328
- const workspaceTaskComplete = async (jobId, taskId, summary) => {
329
- const overview = cachedOverviews.find(item => item.job.id === jobId);
330
- if (!overview)
331
- throw new Error(`Managed job ${jobId} not found`);
332
- const store = runtimeStore(overview.job.repository, overview.job.prNumber);
333
- const job = await store.load(jobId);
334
- const task = job.tasks.find(item => item.id === taskId);
335
- if (!task)
336
- throw new Error(`Task ${taskId} not found`);
337
- await applyOrchestratorCompleteAction(task, summary);
338
- await store.saveTask(task);
339
- await publishOrchestratorTaskCompletion(bus, job, task, summary);
340
- await pool.reconcileTarget(overview.job.repository, overview.job.prNumber, jobId, 'control.task.complete');
341
- return await store.load(jobId);
342
- };
343
- const workspaceTaskAction = (action) => async (jobId, taskId) => {
344
- const overview = cachedOverviews.find(item => item.job.id === jobId);
345
- if (!overview)
346
- throw new Error(`Managed job ${jobId} not found`);
347
- const store = runtimeStore(overview.job.repository, overview.job.prNumber);
348
- return await dashboardTaskAction(store, makeReconciler(store, concurrency), action)(jobId, taskId);
349
- };
350
- const workspaceTaskSetState = async (jobId, taskId, state, target) => {
351
- const overview = dashboardOverview(jobId, target);
352
- if (!overview)
353
- throw new Error(`Managed job ${jobId} not found`);
354
- const store = runtimeStore(overview.job.repository, overview.job.prNumber);
355
- const result = await dashboardTaskSetState(store)(jobId, taskId, state);
356
- await refreshPullRequest(overview.job.repository, overview.job.prNumber, 'dashboard.task-state', { force: true });
357
- return result;
358
- };
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 });
359
343
  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() : '');
344
+ const secret = noGitHub ? '' : value(argv, '--github-webhook-secret') || process.env.AGENTS_RELAY_GITHUB_WEBHOOK_SECRET || (secretFile ? (await readFile(secretFile, 'utf8')).trim() : '');
361
345
  const webhook = secret ? {
362
346
  secret,
363
347
  repositories: new Set(repositories),
364
348
  wake: async (event) => {
365
- 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));
366
350
  if (targets.length === 0)
367
351
  return;
368
352
  for (const target of targets) {
369
- const store = runtimeStore(event.repository, event.pullRequest);
353
+ const binding = await requireLocalJobBinding(target.job.id);
354
+ const store = binding.store;
370
355
  if (event.pullRequestFacts && store.applyPullRequestFacts) {
371
- await store.applyPullRequestFacts({ ...event.pullRequestFacts, observedAt: new Date().toISOString() });
372
- target.githubState = event.pullRequestFacts.state;
356
+ await store.applyPullRequestFacts({ ...event.pullRequestFacts,
357
+ observedAt: event.pullRequestFacts.observedAt ?? new Date().toISOString() });
358
+ target.githubState = await store.pullRequestState?.() ?? target.githubState;
359
+ }
360
+ if (event.publication || event.publishFailure) {
361
+ const relay = binding.relay;
362
+ try {
363
+ const inserted = await relay.recordPublication(target.job.id, event.deliveryId, event.publication, event.publishFailure ?? null, event.publicationObservedAt);
364
+ // Replayed webhook deliveries may retry EventBus delivery; SQLite state remains idempotent.
365
+ const state = await relay.loadPublication(target.job.id);
366
+ target.publication = state?.latest ?? null;
367
+ target.publicationFailure = state?.failure ?? null;
368
+ }
369
+ finally { /* The SQLite registry owns the shared connection. */ }
373
370
  }
374
- await pool.webhookWake(bus, target.job, event);
371
+ // GitHub is an audit input only: no workflow or reconcile wake.
375
372
  }
376
373
  cachedOverviews = dashboardManagedJobs(cachedOverviews);
377
374
  }
@@ -384,14 +381,12 @@ export async function runDaemon(argv) {
384
381
  }
385
382
  if (!cached)
386
383
  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();
384
+ 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
385
  try {
391
386
  const refreshed = (await refreshPullRequest(cached.job.repository, cached.job.prNumber, 'dashboard.selected-job', { force: true })).find(item => item.job.id === jobId);
392
387
  if (!refreshed)
393
388
  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 };
389
+ 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
390
  }
396
391
  catch (error) {
397
392
  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 +394,17 @@ export async function runDaemon(argv) {
399
394
  }
400
395
  };
401
396
  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, {
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,
403
399
  start: workspaceJobAction('start'),
404
400
  reconcile: workspaceJobAction('reconcile'),
405
401
  cancel: workspaceJobAction('cancel'),
406
402
  jobSetState: workspaceJobSetState,
407
403
  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')),
404
+ taskUpdate: managedTaskUpdateHandler(async (jobId) => {
405
+ const binding = await requireLocalJobBinding(jobId);
406
+ return { store: binding.store, repository: binding.job.repository, prNumber: binding.job.prNumber };
407
+ }, async (_repository, _prNumber, jobId) => pool.reconcileLocalJob(jobId, 'control.task.update')),
410
408
  taskStart: workspaceTaskStart,
411
409
  taskRetry: workspaceTaskAction('retry'),
412
410
  taskCancel: workspaceTaskAction('cancel'),
@@ -496,39 +494,46 @@ export async function runDaemon(argv) {
496
494
  }
497
495
  };
498
496
  const flushProjectionsSafely = async () => {
497
+ let externalActionsCompleted = 0;
499
498
  for (const writer of projectionWriters.values()) {
500
499
  try {
501
- await writer.flushOnce();
500
+ externalActionsCompleted += (await writer.flushOnce()).projected;
502
501
  }
503
502
  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) } });
503
+ 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
504
  }
506
505
  }
506
+ // GitHub external acknowledgements are audit-only. Never wake local Graphs.
507
+ if (externalActionsCompleted > 0)
508
+ observability().record({ stage: 'daemon', event: 'github.external_actions_recorded', level: 'info', outcome: 'succeeded', message: String(externalActionsCompleted) + ' external actions acknowledged without lifecycle reconciliation' });
507
509
  };
508
510
  const watchdogDefault = webhook ? SERVICE_DEFAULTS.webhookWatchdogMs : SERVICE_DEFAULTS.watchdogMs;
509
511
  let interval;
510
512
  let scheduleInterval;
511
513
  let projectionInterval;
512
- const stop = () => { if (interval)
514
+ const stop = () => { stopRuntimeStatusEvents(); if (interval)
513
515
  clearInterval(interval); if (scheduleInterval)
514
516
  clearInterval(scheduleInterval); if (projectionInterval)
515
517
  clearInterval(projectionInterval); void Promise.allSettled([stopWatch(), shutdownOwnedProcesses('parent-shutdown')]).finally(() => { for (const store of relayStores.values())
516
518
  store.close(); server.close(() => process.exit(0)); }); };
517
519
  process.once('SIGINT', stop);
518
520
  process.once('SIGTERM', stop);
521
+ await syncGitHubBindingsSafely();
519
522
  await pool.reconcileKnown(cachedOverviews);
520
523
  await scheduleHeartbeatSafely();
521
524
  await flushProjectionsSafely();
522
525
  interval = setInterval(() => void reconcileSafely().finally(flushProjectionsSafely), Number(value(argv, '--interval', String(watchdogDefault))));
523
526
  interval.unref();
524
- projectionInterval = setInterval(() => void flushProjectionsSafely(), Number(value(argv, '--projection-ms', String(SERVICE_DEFAULTS.projectionFlushMs))));
525
- projectionInterval.unref();
527
+ if (!noGitHub) {
528
+ projectionInterval = setInterval(() => void syncGitHubBindingsSafely().then(flushProjectionsSafely), Number(value(argv, '--projection-ms', String(SERVICE_DEFAULTS.projectionFlushMs))));
529
+ projectionInterval.unref();
530
+ }
526
531
  scheduleInterval = setInterval(() => void scheduleHeartbeatSafely(), SCHEDULE_HEARTBEAT_MS);
527
532
  scheduleInterval.unref();
528
533
  console.log('Dashboard listening; repository worker pool active');
529
534
  void refreshAll(true)
530
535
  .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) } }));
536
+ .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
537
  }
533
538
  if (isEntrypoint(import.meta.url))
534
539
  runDaemon(process.argv.slice(2)).catch(error => { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; });