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