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.
Files changed (66) hide show
  1. package/README.md +4 -2
  2. package/dist/adapters.js +26 -2
  3. package/dist/blockers.js +60 -65
  4. package/dist/cli-support.js +33 -1
  5. package/dist/cli.js +72 -86
  6. package/dist/dashboard-actions.js +13 -35
  7. package/dist/dashboard-html.js +1 -1
  8. package/dist/dashboard.js +39 -0
  9. package/dist/execution-recovery.js +37 -0
  10. package/dist/graph-runtime.js +1 -0
  11. package/dist/job-actions.js +53 -0
  12. package/dist/manual-plan.js +15 -17
  13. package/dist/planner-runtime.js +135 -0
  14. package/dist/planner.js +76 -60
  15. package/dist/pool.js +54 -10
  16. package/dist/projected-store.js +314 -0
  17. package/dist/reconciler-review.js +185 -110
  18. package/dist/reconciler-runtime.js +67 -7
  19. package/dist/reconciler.js +134 -282
  20. package/dist/relay-store.js +380 -0
  21. package/dist/relayd-main.js +6 -0
  22. package/dist/relayd.js +122 -29
  23. package/dist/runtime-status.js +2 -1
  24. package/dist/scheduled-jobs.js +40 -31
  25. package/dist/scheduler.js +19 -18
  26. package/dist/store.js +1 -1
  27. package/dist/task-actions.js +139 -0
  28. package/dist/troubleshooter.js +14 -8
  29. package/dist/types.js +3 -3
  30. package/dist/unblock.js +31 -21
  31. package/dist/workflow/authoritative-unblock-v2.js +20 -0
  32. package/dist/workflow/blocker-resolution-v2.js +33 -0
  33. package/dist/workflow/contracts.js +21 -0
  34. package/dist/workflow/definitions-v2.js +75 -0
  35. package/dist/workflow/dispatcher.js +56 -0
  36. package/dist/workflow/entity-creation.js +32 -0
  37. package/dist/workflow/graph-v2.js +391 -0
  38. package/dist/workflow/job-reconcile-v2.js +19 -0
  39. package/dist/workflow/lifecycle.js +82 -4
  40. package/dist/workflow/planner-orchestration-v2.js +58 -0
  41. package/dist/workflow/planner-trigger-v2.js +24 -0
  42. package/dist/workflow/review-v2.js +42 -0
  43. package/dist/workflow/runtime.js +15 -1
  44. package/dist/workflow/schedule-cycle-v2.js +25 -0
  45. package/dist/workflow/scheduled-run-v2.js +30 -0
  46. package/dist/workflow/task-action-policy.js +19 -0
  47. package/dist/workflow/task-reconcile.js +6 -6
  48. package/dist/workflow/task-related-action-v2.js +42 -0
  49. package/dist/workflow/troubleshooter-v2.js +29 -0
  50. package/dist/workspace.js +6 -1
  51. package/package.json +3 -3
  52. package/skills/agents-relay/agents/planner.agent.md +5 -5
  53. package/skills/agents-relay/contracts/task-job-contract.md +2 -51
  54. package/workflow/authoritative-unblock-v2.mmd +15 -0
  55. package/workflow/blocker-resolution-v2.mmd +73 -0
  56. package/workflow/job-lifecycle-v2.mmd +70 -0
  57. package/workflow/job-lifecycle.mmd +2 -2
  58. package/workflow/planner-orchestration-v2.mmd +65 -0
  59. package/workflow/planner-trigger-v2.mmd +32 -0
  60. package/workflow/review-lifecycle-v2.mmd +124 -0
  61. package/workflow/schedule-cycle-v2.mmd +19 -0
  62. package/workflow/scheduled-run-v2.mmd +55 -0
  63. package/workflow/task-lifecycle-v2.mmd +201 -0
  64. package/workflow/task-lifecycle.mmd +3 -0
  65. package/workflow/task-related-action-v2.mmd +28 -0
  66. 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
- import { runScheduledBucket } from './scheduled-jobs.js';
23
- import { scheduleBucket } from './scheduler.js';
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, failOnError = 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
- cachedOverviews = dashboardManagedJobs([...cachedOverviews.filter(item => !scanned.has(item.job.repository)), ...refreshed]);
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') => forceRefresh ? refreshAll(true) : cachedOverviews;
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(...snapshot.overviews);
210
+ cachedOverviews.push(...operational);
181
211
  cachedOverviews = dashboardManagedJobs(cachedOverviews);
182
212
  await persistCache();
183
- pullRequestRefreshCache.set(key, { snapshot, completedAt: Date.now() });
184
- return snapshot;
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 ? new GitHubStore(client, fallback.repository, fallback.prNumber, trusted) : new InMemoryStore();
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 = new GitHubStore(client, overview.job.repository, overview.job.prNumber, trusted);
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 = new GitHubStore(client, overview.job.repository, overview.job.prNumber, trusted);
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 = new GitHubStore(client, overview.job.repository, overview.job.prNumber, trusted);
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 = new GitHubStore(client, overview.job.repository, overview.job.prNumber, trusted);
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 runScheduledBucket(client, repository, trusted, { prNumber: item.job.prNumber, jobId: item.job.id, bucket: scheduleBucket(new Date(item.job.schedule.nextRunAt)) }, { reconcile: async () => { await pool.reconcileTarget(repository, item.job.prNumber, item.job.id); }, idle: async () => { } }, now);
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: scheduled heartbeat unavailable for ${repository}#${item.job.prNumber}: ${error instanceof Error ? error.message : String(error)}\n`);
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); void stopWatch().finally(() => server.close(() => process.exit(0))); };
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 once(server, 'listening');
313
- const startupItems = await refreshAll(true, true); // void refreshAll(true)
314
- await pool.reconcileKnown(startupItems);
315
- // Startup recovery must finish before normal polling or scheduled work can run.
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
- interval = setInterval(() => void reconcileSafely(), Number(value(argv, '--interval', String(watchdogDefault))));
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; });
@@ -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.agentName !== 'reviewer'
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');
@@ -1,49 +1,58 @@
1
1
  import { GitHubStore } from './store.js';
2
2
  import { nextScheduleRun, scheduleBucket } from './scheduler.js';
3
- async function refresh(client, repository, trustedAuthors, ref) {
4
- const store = new GitHubStore(client, repository, ref.prNumber, trustedAuthors);
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(`Scheduled job ${job.id} has no schedule metadata`);
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
- if (active && schedule.concurrencyPolicy === 'skip') {
23
- schedule.lastScheduledSlot = slot.toISOString();
24
- schedule.nextRunAt = nextScheduleRun(schedule, now).toISOString();
25
- job.updatedAt = now.toISOString();
26
- await new GitHubStore(client, repository, ref.prNumber, trustedAuthors).saveJob(job);
27
- return { job, status: 'skipped-active' };
28
- }
29
- if (active && schedule.concurrencyPolicy === 'queue') {
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
- const missedRunPolicy = schedule.missedRunPolicy;
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(`Scheduled job ${job.id} lost its schedule metadata`);
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 { decideJobLifecycle, decideTaskLifecycle } from './workflow/lifecycle.js';
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 { applyJobWorkflowDecision, applyTaskWorkflowDecision } from './workflow/runtime.js';
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 planners = job.tasks.filter(task => task.kind === 'planner');
12
- return planners.some(task => task.state === 'SUCCEEDED' && task.plannerResult?.objective_status === 'satisfied')
13
- && implementationTasks(job).every(task => task.state === 'SUCCEEDED');
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 planners = scopedTasks.filter(task => task.kind === 'planner');
22
- const latest = planners.sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id)).at(-1);
23
- if (latest?.state === 'SUCCEEDED' && latest.plannerResult?.objective_status === 'satisfied') {
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
- const decision = decideTaskLifecycle(task, event, context);
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 = tryTransitionTaskEvent(task, event, context);
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 = decideJobLifecycle(job, event, context);
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
+ }