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.
- package/README.md +56 -20
- package/dist/adapters.js +39 -3
- package/dist/blockers.js +17 -4
- package/dist/cli-blocker.js +12 -30
- package/dist/cli-help.js +22 -17
- package/dist/cli-job-create.js +77 -0
- package/dist/cli-local-merge-config.js +11 -0
- package/dist/cli-projection.js +23 -55
- package/dist/cli-review.js +7 -24
- package/dist/cli-support.js +81 -8
- package/dist/cli-worker-task.js +63 -0
- package/dist/cli.js +223 -185
- package/dist/dashboard-html.js +15 -12
- package/dist/dashboard.js +27 -2
- package/dist/e2e-injection.js +76 -0
- package/dist/github-auth.js +4 -3
- package/dist/github-binding-order.js +15 -0
- package/dist/github-client.js +7 -2
- package/dist/github-pr-archive.js +22 -0
- package/dist/github-webhook.js +43 -10
- package/dist/job-actions.js +29 -0
- package/dist/local-authority-store.js +288 -0
- package/dist/local-commit-events.js +2 -0
- package/dist/local-git-merge.js +124 -0
- package/dist/local-job-path.js +7 -0
- package/dist/local-job-registry.js +29 -0
- package/dist/local-managed-store.js +21 -0
- package/dist/local-merge-delivery.js +53 -0
- package/dist/local-merge-lock.js +103 -0
- package/dist/local-merge-projection.js +60 -0
- package/dist/managed-worker-input.js +13 -6
- package/dist/manual-plan.js +29 -7
- package/dist/no-github.js +11 -0
- package/dist/planned-review-head.js +7 -8
- package/dist/planner-runtime.js +182 -41
- package/dist/planner.js +35 -2
- package/dist/pool.js +89 -136
- package/dist/projected-store.js +20 -3
- package/dist/projection-outbox.js +3 -1
- package/dist/publication-store.js +37 -0
- package/dist/reconciler-cancellation.js +21 -0
- package/dist/reconciler-review.js +99 -29
- package/dist/reconciler-runtime.js +72 -35
- package/dist/reconciler.js +124 -85
- package/dist/relay-store.js +108 -6
- package/dist/relayd-control.js +184 -0
- package/dist/relayd-dashboard-actions.js +108 -0
- package/dist/relayd-utilities.js +107 -0
- package/dist/relayd.js +304 -299
- package/dist/review-generation-fence.js +12 -0
- package/dist/runtime-status-events.js +21 -0
- package/dist/runtime-status.js +21 -7
- package/dist/scheduled-jobs.js +43 -36
- package/dist/service-external-actions.js +27 -0
- package/dist/service-publication.js +29 -0
- package/dist/store-github.js +17 -6
- package/dist/task-actions.js +59 -1
- package/dist/task-update.js +1 -1
- package/dist/troubleshooter.js +21 -2
- package/dist/unblock.js +47 -15
- package/dist/worker-rejection.js +11 -0
- package/dist/workflow/contracts.js +10 -1
- package/dist/workflow/definitions-v2.js +2 -0
- package/dist/workflow/job-reconcile-v2.js +9 -60
- package/dist/workflow/runtime.js +18 -3
- package/dist/workflow-effect-failures.js +30 -0
- package/dist/workspace.js +95 -6
- package/package.json +6 -3
- package/skills/agents-relay/agents/planner.agent.md +3 -0
- package/skills/agents-relay/contracts/orchestrator-workflows.md +4 -7
- package/workflow/job-lifecycle.mmd +19 -60
- 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
|
|
11
|
+
import { RepositoryWorkerPool } from './pool.js';
|
|
10
12
|
import { Reconciler } from './reconciler.js';
|
|
11
|
-
import { dashboardManagedJobs, GitHubStore, InMemoryStore,
|
|
12
|
-
import {
|
|
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 {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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 =
|
|
161
|
-
|
|
162
|
-
const
|
|
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
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
void Promise.resolve().then(() => bus.publish(eventFor(jobId, 'projection', null, 'projection.conflict', 'blocked', conflict.message, 'orchestrator', { ...conflict.data, repository, prNumber }))).catch(() => undefined);
|
|
185
|
-
}
|
|
186
|
-
});
|
|
187
|
-
projectionWriters.set(key, projected.projection);
|
|
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
|
-
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
217
|
-
|
|
205
|
+
githubAuthenticated = false;
|
|
206
|
+
githubAuthError = errorMessage(error);
|
|
218
207
|
}
|
|
219
208
|
};
|
|
220
|
-
const
|
|
221
|
-
if (
|
|
222
|
-
return
|
|
223
|
-
|
|
224
|
-
|
|
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
|
|
232
|
-
const
|
|
233
|
-
|
|
234
|
-
const
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
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
|
|
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 ?
|
|
271
|
-
const dashboardOverview = (
|
|
272
|
-
|
|
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
|
|
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,
|
|
372
|
-
|
|
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
|
-
|
|
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(
|
|
409
|
-
|
|
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
|
|
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
|
-
|
|
525
|
-
|
|
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
|
|
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; });
|