agents-relay 1.0.177 → 2.0.1
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 +4 -2
- package/dist/adapters.js +26 -2
- package/dist/blockers.js +60 -65
- package/dist/cli-support.js +33 -1
- package/dist/cli.js +72 -86
- package/dist/dashboard-actions.js +13 -35
- package/dist/dashboard-html.js +1 -1
- package/dist/dashboard.js +39 -0
- package/dist/execution-recovery.js +37 -0
- package/dist/graph-runtime.js +1 -0
- package/dist/job-actions.js +53 -0
- package/dist/manual-plan.js +15 -17
- package/dist/planner-runtime.js +135 -0
- package/dist/planner.js +76 -60
- package/dist/pool.js +54 -10
- package/dist/projected-store.js +314 -0
- package/dist/reconciler-review.js +185 -110
- package/dist/reconciler-runtime.js +67 -7
- package/dist/reconciler.js +134 -282
- package/dist/relay-store.js +380 -0
- package/dist/relayd-main.js +6 -0
- package/dist/relayd.js +122 -29
- package/dist/runtime-status.js +2 -1
- package/dist/scheduled-jobs.js +40 -31
- package/dist/scheduler.js +19 -18
- package/dist/store.js +1 -1
- package/dist/task-actions.js +139 -0
- package/dist/troubleshooter.js +14 -8
- package/dist/types.js +3 -3
- package/dist/unblock.js +31 -21
- package/dist/workflow/authoritative-unblock-v2.js +20 -0
- package/dist/workflow/blocker-resolution-v2.js +33 -0
- package/dist/workflow/contracts.js +21 -0
- package/dist/workflow/definitions-v2.js +75 -0
- package/dist/workflow/dispatcher.js +56 -0
- package/dist/workflow/entity-creation.js +32 -0
- package/dist/workflow/graph-v2.js +391 -0
- package/dist/workflow/job-reconcile-v2.js +19 -0
- package/dist/workflow/lifecycle.js +82 -4
- package/dist/workflow/planner-orchestration-v2.js +58 -0
- package/dist/workflow/planner-trigger-v2.js +24 -0
- package/dist/workflow/review-v2.js +42 -0
- package/dist/workflow/runtime.js +15 -1
- package/dist/workflow/schedule-cycle-v2.js +25 -0
- package/dist/workflow/scheduled-run-v2.js +30 -0
- package/dist/workflow/task-action-policy.js +19 -0
- package/dist/workflow/task-reconcile.js +6 -6
- package/dist/workflow/task-related-action-v2.js +42 -0
- package/dist/workflow/troubleshooter-v2.js +29 -0
- package/dist/workspace.js +6 -1
- package/package.json +3 -3
- package/skills/agents-relay/agents/planner.agent.md +5 -5
- package/skills/agents-relay/contracts/task-job-contract.md +2 -51
- package/workflow/authoritative-unblock-v2.mmd +15 -0
- package/workflow/blocker-resolution-v2.mmd +73 -0
- package/workflow/job-lifecycle-v2.mmd +70 -0
- package/workflow/job-lifecycle.mmd +2 -2
- package/workflow/planner-orchestration-v2.mmd +65 -0
- package/workflow/planner-trigger-v2.mmd +32 -0
- package/workflow/review-lifecycle-v2.mmd +124 -0
- package/workflow/schedule-cycle-v2.mmd +19 -0
- package/workflow/scheduled-run-v2.mmd +55 -0
- package/workflow/task-lifecycle-v2.mmd +201 -0
- package/workflow/task-lifecycle.mmd +3 -0
- package/workflow/task-related-action-v2.mmd +28 -0
- package/workflow/troubleshooter-v2.mmd +43 -0
package/dist/relayd.js
CHANGED
|
@@ -10,8 +10,12 @@ import { NatsEventBus } from './events.js';
|
|
|
10
10
|
import { RepositoryWorkerPool, aggregateManagedGitHubJobs } from './pool.js';
|
|
11
11
|
import { Reconciler } from './reconciler.js';
|
|
12
12
|
import { dashboardManagedJobs, GitHubStore, InMemoryStore, loadManagedGitHubPullRequestSnapshotGraphql, withGitHubRequestContext } from './store.js';
|
|
13
|
+
import { ProjectedDurableStore } from './projected-store.js';
|
|
14
|
+
import { discoverLocalRelayStoreSnapshots, SQLiteRelayStore } from './relay-store.js';
|
|
13
15
|
import { githubAuthContext } from './github-auth.js';
|
|
14
16
|
import { dashboardJobAction, isEntrypoint, runtimePlanner, runtimeReviewer, startService, SERVICE_DEFAULTS } from './cli.js';
|
|
17
|
+
import { applyJobActivateAction } from './job-actions.js';
|
|
18
|
+
import { applyOrchestratorCompleteAction } from './task-actions.js';
|
|
15
19
|
import { dashboardJobSetState, dashboardTaskAction, dashboardTaskSetState } from './dashboard-actions.js';
|
|
16
20
|
import { codexAndZaiUsageRegistry } from './usage.js';
|
|
17
21
|
import { defaultWorkspaceRoot, discoverWorkspaceRepositories, resolveManagedProjectPath, resolveRepositoryProjectPath } from './workspace.js';
|
|
@@ -19,8 +23,14 @@ import { deriveJobRuntimeStatus } from './runtime-status.js';
|
|
|
19
23
|
import { decisionMode, LayaCliDecisionEngine } from './decision.js';
|
|
20
24
|
import { JsonlDecisionAuditStore } from './decision-audit.js';
|
|
21
25
|
import { summarizeRuntime } from './runtime-summary.js';
|
|
22
|
-
|
|
23
|
-
|
|
26
|
+
export async function overlayOperationalOverviews(overviews, storeFor) {
|
|
27
|
+
return await Promise.all(overviews.map(async (item) => {
|
|
28
|
+
const store = storeFor(item.job.repository, item.job.prNumber);
|
|
29
|
+
await store.hydrate?.(item.job);
|
|
30
|
+
const job = await store.load(item.job.id);
|
|
31
|
+
return { ...item, job };
|
|
32
|
+
}));
|
|
33
|
+
}
|
|
24
34
|
const PR_REFRESH_COALESCE_MS = 1000;
|
|
25
35
|
export const SCHEDULE_HEARTBEAT_MS = 600_000;
|
|
26
36
|
export function dueScheduledOverviews(overviews, now = new Date()) {
|
|
@@ -40,6 +50,7 @@ Options:
|
|
|
40
50
|
--port PORT Dashboard port (default: 8787)
|
|
41
51
|
--concurrency N Maximum concurrent workers (default: 4)
|
|
42
52
|
--interval MS Recovery interval
|
|
53
|
+
--projection-ms MS GitHub projection flush interval (default: 1000)
|
|
43
54
|
--events nats Enable event wakeups
|
|
44
55
|
|
|
45
56
|
Legacy single-job mode also accepts --repo OWNER/REPO --pr NUMBER --id JOB_ID.
|
|
@@ -109,6 +120,20 @@ export async function runDaemon(argv) {
|
|
|
109
120
|
let cachedOverviews = [];
|
|
110
121
|
const githubReadPaths = { workspaceScans: 0, prRefreshes: 0, prRefreshesByTrigger: {}, prRefreshCoalesced: 0, prRefreshCoalescedByTrigger: {} };
|
|
111
122
|
let dormantProbeIndex = 0;
|
|
123
|
+
const relayStores = new Map();
|
|
124
|
+
const projectionWriters = new Map();
|
|
125
|
+
const runtimeStore = (repository, prNumber, seed) => {
|
|
126
|
+
const key = `${repository}#${prNumber}`;
|
|
127
|
+
let relay = relayStores.get(key);
|
|
128
|
+
if (!relay) {
|
|
129
|
+
relay = new SQLiteRelayStore(join(homedir(), '.agents-relay', 'stores', `${repository.replace('/', '_')}-${prNumber}.db`));
|
|
130
|
+
relayStores.set(key, relay);
|
|
131
|
+
}
|
|
132
|
+
const github = new GitHubStore(client, repository, prNumber, trusted, seed);
|
|
133
|
+
const projected = new ProjectedDurableStore(relay, github, seed ?? null, (relatedRepository, relatedPrNumber) => runtimeStore(relatedRepository, relatedPrNumber));
|
|
134
|
+
projectionWriters.set(key, projected.projection);
|
|
135
|
+
return projected;
|
|
136
|
+
};
|
|
112
137
|
const makeReconciler = (store, maxConcurrent) => new Reconciler(store, { owner: `relayd-${process.pid}`, maxConcurrent, leaseMs: Number(value(argv, '--lease-ms', '300000')), adapters: [new CodexAdapter(value(argv, '--codex', 'codex')), new ChatGptAdapter()], continuations: [new CodexThreadContinuation(value(argv, '--codex', 'codex')), new CommandContinuation(), new WebhookContinuation()], planner: runtimePlanner(argv), usageSnapshotProvider: () => usageRegistry.snapshot(), runtimeSummaryProvider: () => ({ chatgptRunning: summarizeRuntime(cachedOverviews.map(item => item.job)).workers.chatgpt }), decisionEngine: new LayaCliDecisionEngine(), decisionMode: decisionMode(), decisionAuditStore, eventBus: bus, reviewer: runtimeReviewer(), projectPathForJob: async (job) => resolveRepositoryProjectPath(repositoryPaths.get(job.repository) ?? await resolveManagedProjectPath(job.repository, explicitProjectPath, workspaceRoot), job.projectPath) });
|
|
113
138
|
if (repositories.length === 0)
|
|
114
139
|
throw new Error(`No Git repositories with GitHub origins found under ${normalized.workspaceRoot ?? defaultWorkspaceRoot()}`);
|
|
@@ -120,7 +145,7 @@ export async function runDaemon(argv) {
|
|
|
120
145
|
cachedOverviews = parsed;
|
|
121
146
|
}
|
|
122
147
|
catch { /* first run or invalid cache */ }
|
|
123
|
-
const refreshAll = async (full = false
|
|
148
|
+
const refreshAll = async (full = false) => {
|
|
124
149
|
if (client.rateLimitStatus().limited)
|
|
125
150
|
return cachedOverviews;
|
|
126
151
|
const scanRepositories = full ? repositories : recoveryScanRepositories(repositories, cachedOverviews, dormantProbeIndex++);
|
|
@@ -130,7 +155,8 @@ export async function runDaemon(argv) {
|
|
|
130
155
|
githubReadPaths.workspaceScans += 1;
|
|
131
156
|
const refreshed = await withGitHubRequestContext(client, 'relayd.workspace-refresh', () => aggregateManagedGitHubJobs(client, scanRepositories, trusted));
|
|
132
157
|
const scanned = new Set(scanRepositories);
|
|
133
|
-
|
|
158
|
+
const merged = dashboardManagedJobs([...cachedOverviews.filter(item => !scanned.has(item.job.repository)), ...refreshed]);
|
|
159
|
+
cachedOverviews = await overlayOperationalOverviews(merged, (repository, prNumber) => runtimeStore(repository, prNumber));
|
|
134
160
|
try {
|
|
135
161
|
await mkdir(cacheDirectory, { recursive: true });
|
|
136
162
|
await writeFile(cacheFile, JSON.stringify(cachedOverviews), 'utf8');
|
|
@@ -139,13 +165,16 @@ export async function runDaemon(argv) {
|
|
|
139
165
|
return cachedOverviews;
|
|
140
166
|
}
|
|
141
167
|
catch (error) {
|
|
142
|
-
if (failOnError)
|
|
143
|
-
throw error;
|
|
144
168
|
process.stderr.write(`warning: workspace job refresh unavailable: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
145
169
|
return cachedOverviews;
|
|
146
170
|
}
|
|
147
171
|
};
|
|
148
|
-
const overviews = async (forceRefresh = false, _trigger = 'internal.unknown') =>
|
|
172
|
+
const overviews = async (forceRefresh = false, _trigger = 'internal.unknown') => {
|
|
173
|
+
if (forceRefresh)
|
|
174
|
+
return refreshAll(true);
|
|
175
|
+
cachedOverviews = await overlayOperationalOverviews(cachedOverviews, (repository, prNumber) => runtimeStore(repository, prNumber));
|
|
176
|
+
return cachedOverviews;
|
|
177
|
+
};
|
|
149
178
|
const persistCache = async () => {
|
|
150
179
|
try {
|
|
151
180
|
await mkdir(cacheDirectory, { recursive: true });
|
|
@@ -176,12 +205,14 @@ export async function runDaemon(argv) {
|
|
|
176
205
|
const flight = (async () => {
|
|
177
206
|
githubReadPaths.prRefreshes += 1;
|
|
178
207
|
const snapshot = await withGitHubRequestContext(client, `relayd.pr-refresh:${trigger}`, () => loadManagedGitHubPullRequestSnapshotGraphql(client, repository, prNumber, trusted));
|
|
208
|
+
const operational = await overlayOperationalOverviews(snapshot.overviews, (repo, pr) => runtimeStore(repo, pr, repo === repository && pr === prNumber ? snapshot.seed : null));
|
|
179
209
|
cachedOverviews = cachedOverviews.filter(item => item.job.repository !== repository || item.job.prNumber !== prNumber);
|
|
180
|
-
cachedOverviews.push(...
|
|
210
|
+
cachedOverviews.push(...operational);
|
|
181
211
|
cachedOverviews = dashboardManagedJobs(cachedOverviews);
|
|
182
212
|
await persistCache();
|
|
183
|
-
|
|
184
|
-
|
|
213
|
+
const operationalSnapshot = { ...snapshot, overviews: operational };
|
|
214
|
+
pullRequestRefreshCache.set(key, { snapshot: operationalSnapshot, completedAt: Date.now() });
|
|
215
|
+
return operationalSnapshot;
|
|
185
216
|
})();
|
|
186
217
|
pullRequestRefreshFlights.set(key, flight);
|
|
187
218
|
try {
|
|
@@ -193,15 +224,15 @@ export async function runDaemon(argv) {
|
|
|
193
224
|
}
|
|
194
225
|
};
|
|
195
226
|
const refreshPullRequest = async (repository, prNumber, trigger, options) => (await refreshPullRequestSnapshot(repository, prNumber, trigger, options)).overviews;
|
|
196
|
-
const pool = new RepositoryWorkerPool(client, repositories, trusted, concurrency, makeReconciler, () => client.rateLimitStatus().limited, (repository, prNumber, trigger) => refreshPullRequestSnapshot(repository, prNumber, trigger));
|
|
227
|
+
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));
|
|
197
228
|
const initial = cachedOverviews;
|
|
198
229
|
const fallback = initial[0]?.job;
|
|
199
|
-
const dashboardStore = fallback ?
|
|
230
|
+
const dashboardStore = fallback ? runtimeStore(fallback.repository, fallback.prNumber) : new InMemoryStore();
|
|
200
231
|
const workspaceJobAction = (action) => async (jobId) => {
|
|
201
232
|
const overview = cachedOverviews.find(item => item.job.id === jobId);
|
|
202
233
|
if (!overview)
|
|
203
234
|
throw new Error(`Managed job ${jobId} not found`);
|
|
204
|
-
const store =
|
|
235
|
+
const store = runtimeStore(overview.job.repository, overview.job.prNumber);
|
|
205
236
|
if (action === 'reconcile') {
|
|
206
237
|
await pool.reconcileTarget(overview.job.repository, overview.job.prNumber, jobId);
|
|
207
238
|
return await store.load(jobId);
|
|
@@ -212,23 +243,56 @@ export async function runDaemon(argv) {
|
|
|
212
243
|
const overview = cachedOverviews.find(item => item.job.id === jobId);
|
|
213
244
|
if (!overview)
|
|
214
245
|
throw new Error(`Managed job ${jobId} not found`);
|
|
215
|
-
const store =
|
|
246
|
+
const store = runtimeStore(overview.job.repository, overview.job.prNumber);
|
|
216
247
|
const result = await dashboardJobSetState(store)(jobId, state);
|
|
217
248
|
await refreshPullRequest(overview.job.repository, overview.job.prNumber, 'dashboard.job-state', { force: true });
|
|
218
249
|
return result;
|
|
219
250
|
};
|
|
251
|
+
const workspaceTaskCreate = async (jobId, task) => {
|
|
252
|
+
const overview = cachedOverviews.find(item => item.job.id === jobId);
|
|
253
|
+
if (!overview)
|
|
254
|
+
throw new Error(`Managed job ${jobId} not found`);
|
|
255
|
+
if (task.jobId !== jobId)
|
|
256
|
+
throw new Error(`Task ${task.id} belongs to ${task.jobId}, not ${jobId}`);
|
|
257
|
+
const store = runtimeStore(overview.job.repository, overview.job.prNumber);
|
|
258
|
+
const job = await store.load(jobId);
|
|
259
|
+
if (['COMPLETED', 'CANCELLED'].includes(job.state))
|
|
260
|
+
throw new Error(`Cannot create task on terminal job ${job.id} (${job.state})`);
|
|
261
|
+
await store.appendTask(task);
|
|
262
|
+
const refreshed = await store.load(jobId);
|
|
263
|
+
if (refreshed.state === 'DRAFT') {
|
|
264
|
+
await applyJobActivateAction(refreshed);
|
|
265
|
+
await store.saveJob(refreshed);
|
|
266
|
+
}
|
|
267
|
+
await pool.reconcileTarget(overview.job.repository, overview.job.prNumber, jobId, 'control.task.create');
|
|
268
|
+
return await store.load(jobId);
|
|
269
|
+
};
|
|
270
|
+
const workspaceTaskComplete = async (jobId, taskId, summary) => {
|
|
271
|
+
const overview = cachedOverviews.find(item => item.job.id === jobId);
|
|
272
|
+
if (!overview)
|
|
273
|
+
throw new Error(`Managed job ${jobId} not found`);
|
|
274
|
+
const store = runtimeStore(overview.job.repository, overview.job.prNumber);
|
|
275
|
+
const job = await store.load(jobId);
|
|
276
|
+
const task = job.tasks.find(item => item.id === taskId);
|
|
277
|
+
if (!task)
|
|
278
|
+
throw new Error(`Task ${taskId} not found`);
|
|
279
|
+
await applyOrchestratorCompleteAction(task, summary);
|
|
280
|
+
await store.saveTask(task);
|
|
281
|
+
await pool.reconcileTarget(overview.job.repository, overview.job.prNumber, jobId, 'control.task.complete');
|
|
282
|
+
return await store.load(jobId);
|
|
283
|
+
};
|
|
220
284
|
const workspaceTaskAction = (action) => async (jobId, taskId) => {
|
|
221
285
|
const overview = cachedOverviews.find(item => item.job.id === jobId);
|
|
222
286
|
if (!overview)
|
|
223
287
|
throw new Error(`Managed job ${jobId} not found`);
|
|
224
|
-
const store =
|
|
288
|
+
const store = runtimeStore(overview.job.repository, overview.job.prNumber);
|
|
225
289
|
return await dashboardTaskAction(store, makeReconciler(store, concurrency), action)(jobId, taskId);
|
|
226
290
|
};
|
|
227
291
|
const workspaceTaskSetState = async (jobId, taskId, state) => {
|
|
228
292
|
const overview = cachedOverviews.find(item => item.job.id === jobId);
|
|
229
293
|
if (!overview)
|
|
230
294
|
throw new Error(`Managed job ${jobId} not found`);
|
|
231
|
-
const store =
|
|
295
|
+
const store = runtimeStore(overview.job.repository, overview.job.prNumber);
|
|
232
296
|
const result = await dashboardTaskSetState(store)(jobId, taskId, state);
|
|
233
297
|
await refreshPullRequest(overview.job.repository, overview.job.prNumber, 'dashboard.task-state', { force: true });
|
|
234
298
|
return result;
|
|
@@ -273,13 +337,14 @@ export async function runDaemon(argv) {
|
|
|
273
337
|
reconcile: workspaceJobAction('reconcile'),
|
|
274
338
|
cancel: workspaceJobAction('cancel'),
|
|
275
339
|
jobSetState: workspaceJobSetState,
|
|
340
|
+
taskCreate: workspaceTaskCreate,
|
|
276
341
|
taskRetry: workspaceTaskAction('retry'),
|
|
277
342
|
taskCancel: workspaceTaskAction('cancel'),
|
|
343
|
+
taskComplete: workspaceTaskComplete,
|
|
278
344
|
taskSetState: workspaceTaskSetState,
|
|
279
345
|
});
|
|
346
|
+
await once(server, 'listening');
|
|
280
347
|
const reconcileSafely = async () => {
|
|
281
|
-
if (client.rateLimitStatus().limited)
|
|
282
|
-
return;
|
|
283
348
|
try {
|
|
284
349
|
await pool.reconcile();
|
|
285
350
|
}
|
|
@@ -288,37 +353,65 @@ export async function runDaemon(argv) {
|
|
|
288
353
|
}
|
|
289
354
|
};
|
|
290
355
|
const scheduleHeartbeatSafely = async () => {
|
|
291
|
-
if (client.rateLimitStatus().limited)
|
|
292
|
-
return;
|
|
293
356
|
const now = new Date();
|
|
294
357
|
for (const item of dueScheduledOverviews(cachedOverviews, now)) {
|
|
295
|
-
const repository = item.job.repository;
|
|
296
358
|
try {
|
|
297
|
-
await
|
|
359
|
+
await pool.scheduleTickSignal(bus, item.job, now);
|
|
360
|
+
}
|
|
361
|
+
catch (error) {
|
|
362
|
+
process.stderr.write(`warning: scheduled heartbeat unavailable for ${item.job.repository}#${item.job.prNumber}: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
};
|
|
366
|
+
const flushProjectionsSafely = async () => {
|
|
367
|
+
for (const writer of projectionWriters.values()) {
|
|
368
|
+
try {
|
|
369
|
+
await writer.flushOnce();
|
|
298
370
|
}
|
|
299
371
|
catch (error) {
|
|
300
|
-
process.stderr.write(`warning:
|
|
372
|
+
process.stderr.write(`warning: GitHub projection flush unavailable: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
301
373
|
}
|
|
302
374
|
}
|
|
303
375
|
};
|
|
304
376
|
const watchdogDefault = webhook ? SERVICE_DEFAULTS.webhookWatchdogMs : SERVICE_DEFAULTS.watchdogMs;
|
|
305
377
|
let interval;
|
|
306
378
|
let scheduleInterval;
|
|
379
|
+
let projectionInterval;
|
|
307
380
|
const stop = () => { if (interval)
|
|
308
381
|
clearInterval(interval); if (scheduleInterval)
|
|
309
|
-
clearInterval(scheduleInterval);
|
|
382
|
+
clearInterval(scheduleInterval); if (projectionInterval)
|
|
383
|
+
clearInterval(projectionInterval); void stopWatch().finally(() => { for (const store of relayStores.values())
|
|
384
|
+
store.close(); server.close(() => process.exit(0)); }); };
|
|
310
385
|
process.once('SIGINT', stop);
|
|
311
386
|
process.once('SIGTERM', stop);
|
|
312
|
-
await
|
|
313
|
-
const
|
|
314
|
-
|
|
315
|
-
|
|
387
|
+
const localSnapshots = await discoverLocalRelayStoreSnapshots(join(homedir(), '.agents-relay', 'stores'));
|
|
388
|
+
const knownRepositories = new Set(repositories);
|
|
389
|
+
const localOverviews = localSnapshots.flatMap(snapshot => {
|
|
390
|
+
if (!snapshot.facts)
|
|
391
|
+
return [];
|
|
392
|
+
return snapshot.jobs.filter(job => knownRepositories.has(job.repository)).map(job => ({
|
|
393
|
+
job, githubState: snapshot.facts.state, draft: false, mergedAt: null, closedAt: null, publication: null,
|
|
394
|
+
}));
|
|
395
|
+
});
|
|
396
|
+
const byJob = new Map();
|
|
397
|
+
for (const item of cachedOverviews)
|
|
398
|
+
byJob.set(item.job.repository + '#' + item.job.prNumber + ':' + item.job.id, item);
|
|
399
|
+
for (const item of localOverviews)
|
|
400
|
+
byJob.set(item.job.repository + '#' + item.job.prNumber + ':' + item.job.id, item);
|
|
401
|
+
cachedOverviews = dashboardManagedJobs([...byJob.values()]);
|
|
402
|
+
await pool.reconcileKnown(cachedOverviews);
|
|
316
403
|
await scheduleHeartbeatSafely();
|
|
317
|
-
|
|
404
|
+
await flushProjectionsSafely();
|
|
405
|
+
interval = setInterval(() => void reconcileSafely().finally(flushProjectionsSafely), Number(value(argv, '--interval', String(watchdogDefault))));
|
|
318
406
|
interval.unref();
|
|
407
|
+
projectionInterval = setInterval(() => void flushProjectionsSafely(), Number(value(argv, '--projection-ms', String(SERVICE_DEFAULTS.projectionFlushMs))));
|
|
408
|
+
projectionInterval.unref();
|
|
319
409
|
scheduleInterval = setInterval(() => void scheduleHeartbeatSafely(), SCHEDULE_HEARTBEAT_MS);
|
|
320
410
|
scheduleInterval.unref();
|
|
321
411
|
console.log('Dashboard listening; repository worker pool active');
|
|
412
|
+
void refreshAll(true)
|
|
413
|
+
.then(items => pool.reconcileKnown(items))
|
|
414
|
+
.catch(error => process.stderr.write('warning: startup remote refresh unavailable: ' + (error instanceof Error ? error.message : String(error)) + '\n'));
|
|
322
415
|
}
|
|
323
416
|
if (isEntrypoint(import.meta.url))
|
|
324
417
|
runDaemon(process.argv.slice(2)).catch(error => { console.error(error instanceof Error ? error.message : String(error)); process.exitCode = 1; });
|
package/dist/runtime-status.js
CHANGED
|
@@ -2,7 +2,8 @@ function isSuperseded(task) {
|
|
|
2
2
|
return task.state === 'CANCELLED' && typeof task.error === 'string' && task.error.startsWith('Superseded ');
|
|
3
3
|
}
|
|
4
4
|
export function implementationTasks(job) {
|
|
5
|
-
return job.tasks.filter(task => task.
|
|
5
|
+
return job.tasks.filter(task => task.kind !== 'planner'
|
|
6
|
+
&& task.agentName !== 'reviewer'
|
|
6
7
|
&& task.agentName !== 'troubleshooter'
|
|
7
8
|
&& !isSuperseded(task)
|
|
8
9
|
&& task.state !== 'UNBLOCKED');
|
package/dist/scheduled-jobs.js
CHANGED
|
@@ -1,49 +1,58 @@
|
|
|
1
1
|
import { GitHubStore } from './store.js';
|
|
2
2
|
import { nextScheduleRun, scheduleBucket } from './scheduler.js';
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
import { dispatchScheduledRun } from './workflow/scheduled-run-v2.js';
|
|
4
|
+
async function refresh(client, repository, trustedAuthors, ref, storeFactory) {
|
|
5
|
+
const store = storeFactory ? storeFactory() : new GitHubStore(client, repository, ref.prNumber, trustedAuthors);
|
|
5
6
|
return await store.load(ref.jobId);
|
|
6
7
|
}
|
|
7
|
-
export async function runScheduledBucket(client, repository, trustedAuthors, ref, engine, now = new Date()) {
|
|
8
|
-
let job = await refresh(client, repository, trustedAuthors, ref);
|
|
8
|
+
export async function runScheduledBucket(client, repository, trustedAuthors, ref, engine, now = new Date(), storeFactory) {
|
|
9
|
+
let job = await refresh(client, repository, trustedAuthors, ref, storeFactory);
|
|
9
10
|
const schedule = job.schedule;
|
|
10
11
|
if (!schedule)
|
|
11
|
-
throw new Error(
|
|
12
|
+
throw new Error('Scheduled job ' + job.id + ' has no schedule metadata');
|
|
12
13
|
const slot = new Date(schedule.nextRunAt);
|
|
13
|
-
if (scheduleBucket(slot) !== ref.bucket || slot.getTime() > now.getTime()) {
|
|
14
|
-
return { job, status: 'skipped-idempotent' };
|
|
15
|
-
}
|
|
16
|
-
if (schedule.lastScheduledSlot && new Date(schedule.lastScheduledSlot).getTime() >= slot.getTime()) {
|
|
17
|
-
return { job, status: 'skipped-idempotent' };
|
|
18
|
-
}
|
|
19
|
-
if (job.state !== 'ACTIVE')
|
|
20
|
-
return { job, status: 'skipped-idempotent' };
|
|
21
14
|
const active = job.tasks.some(task => ['QUEUED', 'RUNNING'].includes(task.state));
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
15
|
+
const schedulerStore = storeFactory ? storeFactory() : new GitHubStore(client, repository, ref.prNumber, trustedAuthors);
|
|
16
|
+
let actionStatus = null;
|
|
17
|
+
const decision = await dispatchScheduledRun({
|
|
18
|
+
slotDue: scheduleBucket(slot) === ref.bucket && slot.getTime() <= now.getTime(),
|
|
19
|
+
slotUnclaimed: !schedule.lastScheduledSlot || new Date(schedule.lastScheduledSlot).getTime() < slot.getTime(),
|
|
20
|
+
jobActive: job.state === 'ACTIVE', hasActiveTasks: active,
|
|
21
|
+
concurrencySkip: schedule.concurrencyPolicy === 'skip', concurrencyQueue: schedule.concurrencyPolicy === 'queue',
|
|
22
|
+
runMissed: schedule.missedRunPolicy === 'run',
|
|
23
|
+
}, async (actionId) => {
|
|
24
|
+
if (actionId === 'schedule.skipActiveAdvance') {
|
|
25
|
+
schedule.lastScheduledSlot = slot.toISOString();
|
|
26
|
+
schedule.nextRunAt = nextScheduleRun(schedule, now).toISOString();
|
|
27
|
+
job.updatedAt = now.toISOString();
|
|
28
|
+
await schedulerStore.saveJob(job);
|
|
29
|
+
actionStatus = 'skipped-active';
|
|
30
|
+
return {};
|
|
31
|
+
}
|
|
32
|
+
if (actionId === 'schedule.claimFromSlot' || actionId === 'schedule.claimFromNow') {
|
|
33
|
+
schedule.lastScheduledSlot = slot.toISOString();
|
|
34
|
+
schedule.currentRunSlot = slot.toISOString();
|
|
35
|
+
schedule.nextRunAt = nextScheduleRun(schedule, actionId === 'schedule.claimFromSlot' ? slot : now).toISOString();
|
|
36
|
+
job.updatedAt = now.toISOString();
|
|
37
|
+
await schedulerStore.saveJob(job);
|
|
38
|
+
job = await schedulerStore.load(job.id);
|
|
39
|
+
return {};
|
|
40
|
+
}
|
|
41
|
+
throw new Error('Unsupported schedule graph action ' + actionId);
|
|
42
|
+
});
|
|
43
|
+
if (!decision.accepted || decision.target === 'skip_idempotent')
|
|
44
|
+
return { job, status: 'skipped-idempotent' };
|
|
45
|
+
if (actionStatus === 'skipped-active' || decision.target === 'skip_active')
|
|
30
46
|
return { job, status: 'skipped-active' };
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
schedule.lastScheduledSlot = slot.toISOString();
|
|
34
|
-
schedule.currentRunSlot = slot.toISOString();
|
|
35
|
-
schedule.nextRunAt = nextScheduleRun(schedule, missedRunPolicy === 'run' ? slot : now).toISOString();
|
|
36
|
-
job.updatedAt = now.toISOString();
|
|
37
|
-
const schedulerStore = new GitHubStore(client, repository, ref.prNumber, trustedAuthors);
|
|
38
|
-
await schedulerStore.saveJob(job);
|
|
39
|
-
job = await schedulerStore.load(job.id);
|
|
47
|
+
if (decision.target !== 'claimed')
|
|
48
|
+
return { job, status: 'skipped-idempotent' };
|
|
40
49
|
if (!engine)
|
|
41
50
|
throw new Error('Scheduled jobs require the local relayd scheduler engine');
|
|
42
51
|
await engine.reconcile(job.id);
|
|
43
52
|
await engine.idle();
|
|
44
53
|
const finished = await schedulerStore.load(job.id);
|
|
45
54
|
if (!finished.schedule)
|
|
46
|
-
throw new Error(
|
|
55
|
+
throw new Error('Scheduled job ' + job.id + ' lost its schedule metadata');
|
|
47
56
|
finished.schedule.lastRunAt = now.toISOString();
|
|
48
57
|
finished.updatedAt = now.toISOString();
|
|
49
58
|
await schedulerStore.saveJob(finished);
|
package/dist/scheduler.js
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
import { implementationTasks } from './runtime-status.js';
|
|
2
|
-
import {
|
|
2
|
+
import { dispatchJobLifecycle, dispatchTaskLifecycle, dispatchTaskLifecycleSync } from './workflow/lifecycle.js';
|
|
3
3
|
import { reconcileTaskDependencies } from './workflow/task-reconcile.js';
|
|
4
4
|
export { dependencyReady, detectCycles } from './workflow/task-reconcile.js';
|
|
5
|
-
import {
|
|
5
|
+
import { dispatchScheduleCycle } from './workflow/schedule-cycle-v2.js';
|
|
6
6
|
export function completionGateSatisfied(job) {
|
|
7
7
|
if (job.schedule)
|
|
8
8
|
return false;
|
|
9
9
|
if (job.executionMode !== 'autonomous')
|
|
10
10
|
return true;
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
const planSatisfied = (job.plans ?? []).some(plan => plan.status === 'SUCCEEDED' && plan.objectiveStatus === 'satisfied');
|
|
12
|
+
const legacySatisfied = (job.plans?.length ?? 0) === 0 && job.tasks.some(task => task.kind === 'planner' && task.state === 'SUCCEEDED' && task.plannerResult?.objective_status === 'satisfied');
|
|
13
|
+
return (planSatisfied || legacySatisfied) && implementationTasks(job).every(task => task.state === 'SUCCEEDED');
|
|
14
14
|
}
|
|
15
15
|
export function schedule(job) {
|
|
16
16
|
reconcileTaskDependencies(job);
|
|
@@ -18,16 +18,21 @@ export function schedule(job) {
|
|
|
18
18
|
? job.tasks.filter(task => Date.parse(task.createdAt) >= Date.parse(job.schedule?.currentRunSlot ?? ''))
|
|
19
19
|
: job.tasks;
|
|
20
20
|
if (job.schedule) {
|
|
21
|
-
const
|
|
22
|
-
const
|
|
23
|
-
|
|
21
|
+
const slot = Date.parse(job.schedule.currentRunSlot ?? '');
|
|
22
|
+
const scopedPlans = (job.plans ?? []).filter(plan => !Number.isFinite(slot) || Date.parse(plan.createdAt) >= slot).sort((a, b) => a.round - b.round || a.createdAt.localeCompare(b.createdAt));
|
|
23
|
+
const latestPlan = scopedPlans.at(-1);
|
|
24
|
+
const legacyLatest = scopedPlans.length === 0 ? scopedTasks.filter(task => task.kind === 'planner').sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id)).at(-1) : undefined;
|
|
25
|
+
dispatchScheduleCycle({
|
|
26
|
+
hasCurrentSlot: Boolean(job.schedule.currentRunSlot),
|
|
27
|
+
plannerSatisfied: latestPlan ? latestPlan.status === 'SUCCEEDED' && latestPlan.objectiveStatus === 'satisfied' : legacyLatest?.state === 'SUCCEEDED' && legacyLatest.plannerResult?.objective_status === 'satisfied',
|
|
28
|
+
}, () => {
|
|
24
29
|
const slot = job.schedule.currentRunSlot ? Date.parse(job.schedule.currentRunSlot) : Number.NaN;
|
|
25
30
|
const lastRun = job.schedule.lastRunAt ? Date.parse(job.schedule.lastRunAt) : Number.NaN;
|
|
26
31
|
if (Number.isFinite(slot) && (!Number.isFinite(lastRun) || lastRun < slot))
|
|
27
32
|
job.schedule.lastRunAt = new Date().toISOString();
|
|
28
33
|
job.schedule.currentRunSlot = null;
|
|
29
34
|
job.updatedAt = new Date().toISOString();
|
|
30
|
-
}
|
|
35
|
+
});
|
|
31
36
|
}
|
|
32
37
|
return job;
|
|
33
38
|
}
|
|
@@ -38,27 +43,23 @@ export function extendRetryBudget(task, windowSize = 3) {
|
|
|
38
43
|
return Math.max(windowSize, (Math.floor(usedOrBudgeted / windowSize) + 1) * windowSize);
|
|
39
44
|
}
|
|
40
45
|
export function tryTransitionTaskEvent(task, event, context = {}) {
|
|
41
|
-
|
|
42
|
-
if (decision.accepted && decision.target)
|
|
43
|
-
applyTaskWorkflowDecision(task, decision);
|
|
44
|
-
return decision;
|
|
46
|
+
return dispatchTaskLifecycleSync(task, event, context);
|
|
45
47
|
}
|
|
46
|
-
export async function transitionTaskEvent(task, event, context = {}) {
|
|
48
|
+
export async function transitionTaskEvent(task, event, context = {}, executeEffect, persist) {
|
|
47
49
|
const from = task.state;
|
|
48
|
-
const decision =
|
|
50
|
+
const decision = await dispatchTaskLifecycle(task, event, context, executeEffect, persist);
|
|
49
51
|
if (!decision.accepted || !decision.target)
|
|
50
52
|
throw new Error(decision.reason === 'ambiguous'
|
|
51
53
|
? `Ambiguous workflow transition from ${from} on ${event}`
|
|
52
54
|
: `No enabled workflow transition from ${from} on ${event}`);
|
|
53
55
|
return decision;
|
|
54
56
|
}
|
|
55
|
-
export async function transitionJobEvent(job, event, context = {}) {
|
|
56
|
-
const decision =
|
|
57
|
+
export async function transitionJobEvent(job, event, context = {}, executeEffect, persist) {
|
|
58
|
+
const decision = await dispatchJobLifecycle(job, event, context, executeEffect, persist);
|
|
57
59
|
if (!decision.accepted || !decision.target)
|
|
58
60
|
throw new Error(decision.reason === 'ambiguous'
|
|
59
61
|
? `Ambiguous workflow transition from ${job.state} on ${event}`
|
|
60
62
|
: `No enabled workflow transition from ${job.state} on ${event}`);
|
|
61
|
-
applyJobWorkflowDecision(job, decision);
|
|
62
63
|
return decision;
|
|
63
64
|
}
|
|
64
65
|
export const SCHEDULE_RESOLUTION_MS = 600_000;
|
package/dist/store.js
CHANGED
|
@@ -126,7 +126,6 @@ export class InMemoryStore {
|
|
|
126
126
|
}
|
|
127
127
|
}
|
|
128
128
|
export async function withGitHubRequestContext(client, context, work) { return client.withRequestContext ? client.withRequestContext(context, work) : work(); }
|
|
129
|
-
export { createGitHubAppJwt, GhApiClient } from './github-client.js';
|
|
130
129
|
async function githubGraphql(client, query, variables) {
|
|
131
130
|
if (!client.graphql)
|
|
132
131
|
throw new Error('GitHub GraphQL client is required');
|
|
@@ -257,6 +256,7 @@ export async function resolvePullRequest(client, repository, options) {
|
|
|
257
256
|
throw error;
|
|
258
257
|
}
|
|
259
258
|
}
|
|
259
|
+
export { GhApiClient, createGitHubAppJwt } from './github-client.js';
|
|
260
260
|
export { GitHubStore } from './store-github.js';
|
|
261
261
|
export async function listManagedGitHubJobs(client, repository, trustedAuthors) {
|
|
262
262
|
if (!client.graphql)
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { extendRetryBudget, transitionTaskEvent, tryTransitionTaskEvent } from './scheduler.js';
|
|
2
|
+
import { selectRelatedTaskMutations } from './workflow/task-action-policy.js';
|
|
3
|
+
export const TASK_RETRY_ACTION = 'task.retry';
|
|
4
|
+
export const TASK_UNBLOCK_RETRY_ACTION = 'task.unblock.retry';
|
|
5
|
+
export const TASK_CANCEL_ACTION = 'task.cancel';
|
|
6
|
+
export const TASK_RELEASE_ACTION = 'task.release';
|
|
7
|
+
export const TASK_HOLD_ACTION = 'task.hold';
|
|
8
|
+
export const TASK_COMPLETE_ACTION = 'task.complete';
|
|
9
|
+
export const TASK_QUEUE_ACTION = 'task.queue';
|
|
10
|
+
function lifecycleRejection(task, action) {
|
|
11
|
+
return new Error(`Task ${task.id} is not ${action} (${task.state})`);
|
|
12
|
+
}
|
|
13
|
+
async function transitionTaskAction(task, event, action, facts = {}) {
|
|
14
|
+
try {
|
|
15
|
+
await transitionTaskEvent(task, event, facts);
|
|
16
|
+
}
|
|
17
|
+
catch (error) {
|
|
18
|
+
if (error instanceof Error && error.message.startsWith('No enabled workflow transition')) {
|
|
19
|
+
throw lifecycleRejection(task, action);
|
|
20
|
+
}
|
|
21
|
+
throw error;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function clearExecution(task) {
|
|
25
|
+
task.error = null;
|
|
26
|
+
task.leaseOwner = null;
|
|
27
|
+
task.leaseExpiresAt = null;
|
|
28
|
+
task.executionId = null;
|
|
29
|
+
}
|
|
30
|
+
/** Shared task.retry mutation. The canonical lifecycle graph is the sole legality authority. */
|
|
31
|
+
export async function applyTaskRetryAction(task, policy = {}) {
|
|
32
|
+
if (task.kind === 'planner')
|
|
33
|
+
throw new Error(`Legacy planner Task ${task.id} is read-only; retry the Plan instead`);
|
|
34
|
+
await transitionTaskAction(task, 'retry', 'restartable');
|
|
35
|
+
if (policy.extendBudget)
|
|
36
|
+
task.maxAttempts = extendRetryBudget(task);
|
|
37
|
+
clearExecution(task);
|
|
38
|
+
return task;
|
|
39
|
+
}
|
|
40
|
+
/** Fixed-job authoritative unblock continuation: legality is owned by the lifecycle graph. */
|
|
41
|
+
export async function applyTaskUnblockRetryAction(task) {
|
|
42
|
+
await transitionTaskAction(task, 'retry', 'retryable after unblock');
|
|
43
|
+
task.maxAttempts = extendRetryBudget(task);
|
|
44
|
+
clearExecution(task);
|
|
45
|
+
delete task.blockedBy;
|
|
46
|
+
return task;
|
|
47
|
+
}
|
|
48
|
+
export function tryApplyTaskCancelAction(task, reason) {
|
|
49
|
+
if (task.kind === 'planner')
|
|
50
|
+
return false;
|
|
51
|
+
const decision = tryTransitionTaskEvent(task, 'cancel');
|
|
52
|
+
if (!decision.accepted)
|
|
53
|
+
return false;
|
|
54
|
+
task.error = reason;
|
|
55
|
+
task.leaseOwner = null;
|
|
56
|
+
task.leaseExpiresAt = null;
|
|
57
|
+
task.executionId = null;
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
export async function applyTaskCancelAction(task, reason) {
|
|
61
|
+
if (task.kind === 'planner')
|
|
62
|
+
throw new Error(`Legacy planner Task ${task.id} is read-only`);
|
|
63
|
+
if (!tryApplyTaskCancelAction(task, reason))
|
|
64
|
+
throw lifecycleRejection(task, 'cancellable');
|
|
65
|
+
return task;
|
|
66
|
+
}
|
|
67
|
+
export async function applyTaskReleaseAction(task) {
|
|
68
|
+
await transitionTaskAction(task, 'release', 'releasable');
|
|
69
|
+
return task;
|
|
70
|
+
}
|
|
71
|
+
export async function applyTaskHoldAction(task) {
|
|
72
|
+
await transitionTaskAction(task, 'hold', 'holdable');
|
|
73
|
+
return task;
|
|
74
|
+
}
|
|
75
|
+
export async function applyTaskQueueAction(job, task) {
|
|
76
|
+
const taskMap = new Map(job.tasks.map(item => [item.id, item]));
|
|
77
|
+
const dependenciesSatisfied = task.dependencies.every(id => taskMap.get(id)?.state === 'SUCCEEDED');
|
|
78
|
+
await transitionTaskAction(task, 'queue', 'queueable', { dependenciesSatisfied });
|
|
79
|
+
clearExecution(task);
|
|
80
|
+
return task;
|
|
81
|
+
}
|
|
82
|
+
export async function applyOrchestratorCompleteAction(task, summary) {
|
|
83
|
+
if (task.adapter !== 'orchestrator')
|
|
84
|
+
throw new Error('task complete is only valid for adapter=orchestrator self-tasks');
|
|
85
|
+
const originalState = task.state;
|
|
86
|
+
tryTransitionTaskEvent(task, 'task.started');
|
|
87
|
+
task.attempt = Math.max(task.attempt, 1);
|
|
88
|
+
task.result = { summary };
|
|
89
|
+
clearExecution(task);
|
|
90
|
+
try {
|
|
91
|
+
await transitionTaskEvent(task, 'task.completed');
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
if (error instanceof Error && error.message.startsWith('No enabled workflow transition')) {
|
|
95
|
+
throw new Error(`Task ${task.id} is ${originalState}; only QUEUED or RUNNING orchestrator tasks can be completed`);
|
|
96
|
+
}
|
|
97
|
+
throw error;
|
|
98
|
+
}
|
|
99
|
+
return task;
|
|
100
|
+
}
|
|
101
|
+
async function applySelectedRelatedTaskMutations(job, mutations) {
|
|
102
|
+
const changed = [];
|
|
103
|
+
for (const mutation of mutations) {
|
|
104
|
+
const related = job.tasks.find(item => item.id === mutation.taskId);
|
|
105
|
+
if (!related)
|
|
106
|
+
throw new Error(`Related task ${mutation.taskId} not found`);
|
|
107
|
+
if (mutation.mutation === 'cancel')
|
|
108
|
+
await applyTaskCancelAction(related, mutation.reason);
|
|
109
|
+
changed.push(related);
|
|
110
|
+
}
|
|
111
|
+
return changed;
|
|
112
|
+
}
|
|
113
|
+
/** Compatibility bridge: domain mutation only; adapters own wake/reconcile/output behavior. */
|
|
114
|
+
export async function dispatchTaskAction(store, request) {
|
|
115
|
+
const job = await store.load(request.jobId);
|
|
116
|
+
const task = job.tasks.find(item => item.id === request.taskId);
|
|
117
|
+
if (!task)
|
|
118
|
+
throw new Error(`Task ${request.taskId} not found`);
|
|
119
|
+
const relatedMutations = selectRelatedTaskMutations(job, task, request.id);
|
|
120
|
+
if (request.id === TASK_RETRY_ACTION)
|
|
121
|
+
await applyTaskRetryAction(task, { extendBudget: true });
|
|
122
|
+
else if (request.id === TASK_UNBLOCK_RETRY_ACTION)
|
|
123
|
+
await applyTaskUnblockRetryAction(task);
|
|
124
|
+
else if (request.id === TASK_CANCEL_ACTION)
|
|
125
|
+
await applyTaskCancelAction(task, 'Cancelled by user');
|
|
126
|
+
else if (request.id === TASK_RELEASE_ACTION)
|
|
127
|
+
await applyTaskReleaseAction(task);
|
|
128
|
+
else if (request.id === TASK_HOLD_ACTION)
|
|
129
|
+
await applyTaskHoldAction(task);
|
|
130
|
+
else if (request.id === TASK_QUEUE_ACTION)
|
|
131
|
+
await applyTaskQueueAction(job, task);
|
|
132
|
+
else
|
|
133
|
+
throw new Error(`Unsupported task action ${request.id}`);
|
|
134
|
+
const changedRelated = await applySelectedRelatedTaskMutations(job, relatedMutations);
|
|
135
|
+
await store.saveTask(task);
|
|
136
|
+
for (const related of changedRelated)
|
|
137
|
+
await store.saveTask(related);
|
|
138
|
+
return { job, task };
|
|
139
|
+
}
|