agents-relay 2.0.169 → 2.0.171

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 (74) hide show
  1. package/README.md +13 -19
  2. package/dist/adapters.js +2 -8
  3. package/dist/blockers.js +3 -2
  4. package/dist/cli-help.js +3 -3
  5. package/dist/cli-job-create.js +2 -4
  6. package/dist/cli-support.js +5 -16
  7. package/dist/cli.js +9 -11
  8. package/dist/dashboard-html.js +5 -9
  9. package/dist/dashboard-style.js +0 -1
  10. package/dist/execution-recovery.js +1 -1
  11. package/dist/force-restate-validation.js +11 -1
  12. package/dist/interactive-resume.js +1 -1
  13. package/dist/job-actions.js +1 -1
  14. package/dist/job-pause.js +1 -1
  15. package/dist/legacy-blocked-task-migration.js +1 -1
  16. package/dist/local-authority-store.js +0 -57
  17. package/dist/local-git-merge.js +1 -2
  18. package/dist/local-merge-delivery.js +1 -1
  19. package/dist/markers.js +5 -6
  20. package/dist/planned-review-head.js +2 -2
  21. package/dist/planner-runtime.js +3 -2
  22. package/dist/planner.js +2 -7
  23. package/dist/pool.js +7 -36
  24. package/dist/reconciler-review.js +4 -5
  25. package/dist/reconciler.js +11 -36
  26. package/dist/relay-create.js +3 -3
  27. package/dist/relay-job-snapshot-reads.js +6 -2
  28. package/dist/relay-store.js +2 -2
  29. package/dist/relay-workflow-operations.js +2 -2
  30. package/dist/relayd-control.js +9 -7
  31. package/dist/relayd-utilities.js +0 -4
  32. package/dist/relayd.js +7 -24
  33. package/dist/runtime-status.js +0 -19
  34. package/dist/single-job-task-control.js +1 -1
  35. package/dist/store.js +1 -1
  36. package/dist/task-actions.js +3 -3
  37. package/dist/task-recovery.js +2 -2
  38. package/dist/task-transitions.js +61 -0
  39. package/dist/troubleshooter-fallback.js +1 -1
  40. package/dist/troubleshooter.js +1 -4
  41. package/dist/types.js +1 -2
  42. package/dist/unblock.js +5 -9
  43. package/dist/worker-rejection.js +1 -1
  44. package/dist/workflow/authoritative-conditions.js +1 -1
  45. package/dist/workflow/blocker-resolution-v2.js +2 -4
  46. package/dist/workflow/contracts.js +0 -1
  47. package/dist/workflow/definitions-v2.js +3 -6
  48. package/dist/workflow/durable-transition.js +1 -1
  49. package/dist/workflow/force-restate-validation-v2.js +2 -1
  50. package/dist/workflow/orchestrator-rescue.js +1 -1
  51. package/dist/workflow/planner-orchestration-v2.js +2 -5
  52. package/dist/workflow/planner-trigger-v2.js +1 -2
  53. package/dist/workflow/review-v2.js +0 -1
  54. package/dist/workflow/task-reconcile.js +1 -7
  55. package/dist/workflow-effect-failures.js +1 -1
  56. package/dist/workspace.js +1 -1
  57. package/package.json +3 -4
  58. package/skills/agents-relay/SKILL.md +6 -6
  59. package/skills/agents-relay/agents/planner.agent.md +3 -3
  60. package/skills/agents-relay/contracts/event-contract.md +10 -0
  61. package/skills/agents-relay/contracts/orchestrator-workflows.md +2 -2
  62. package/skills/agents-relay/contracts/task-job-contract.md +5 -5
  63. package/workflow/blocker-resolution.mmd +1 -7
  64. package/workflow/force-restate-validation.mmd +7 -2
  65. package/workflow/planner-orchestration.mmd +2 -5
  66. package/workflow/planner-trigger.mmd +2 -9
  67. package/workflow/review-lifecycle.mmd +2 -8
  68. package/dist/e2e-injection.js +0 -76
  69. package/dist/scheduled-jobs.js +0 -67
  70. package/dist/scheduler.js +0 -214
  71. package/dist/workflow/schedule-cycle-v2.js +0 -25
  72. package/dist/workflow/scheduled-run-v2.js +0 -30
  73. package/workflow/schedule-cycle.mmd +0 -23
  74. package/workflow/scheduled-run.mmd +0 -59
@@ -3,8 +3,6 @@ import { registerBlockerDependency, resolveBlockerDependencies } from './blocker
3
3
  import { samePlanDefinition, sameTaskDefinition, DurableDefinitionConflictError, planDefinitionDivergence, taskDefinitionDivergence } from './store-task-definition.js';
4
4
  import { mergeInboundJobSnapshot } from './relay-store.js';
5
5
  import { publishLocalCommit } from './local-commit-events.js';
6
- import { decideScheduledRun } from './workflow/scheduled-run-v2.js';
7
- import { nextScheduleRun, scheduleBucket } from './scheduler.js';
8
6
  /**
9
7
  * V2 authoritative lifecycle boundary.
10
8
  *
@@ -183,61 +181,6 @@ export class LocalAuthorityDurableStore {
183
181
  Object.assign(job, structuredClone(updated));
184
182
  publishLocalCommit(updated);
185
183
  }
186
- /** Claim a schedule slot under one SQLite IMMEDIATE transaction, across all processes. */
187
- async claimScheduledSlot(jobId, bucket, now) {
188
- if (!this.authority.mutateJob)
189
- throw new Error('Atomic SQLite schedule claim is required');
190
- let status = 'skipped-idempotent';
191
- const result = await this.authority.mutateJob(jobId, current => {
192
- const schedule = current.schedule;
193
- if (!schedule)
194
- throw new Error('Scheduled Job ' + jobId + ' has no schedule metadata');
195
- const slot = new Date(schedule.nextRunAt);
196
- const decision = decideScheduledRun({
197
- slotDue: scheduleBucket(slot) === bucket && slot.getTime() <= now.getTime(),
198
- slotUnclaimed: !schedule.lastScheduledSlot || new Date(schedule.lastScheduledSlot).getTime() < slot.getTime(),
199
- jobActive: current.state === 'ACTIVE',
200
- hasActiveTasks: current.tasks.some(task => ['QUEUED', 'RUNNING'].includes(task.state)),
201
- concurrencySkip: schedule.concurrencyPolicy === 'skip',
202
- concurrencyQueue: schedule.concurrencyPolicy === 'queue',
203
- runMissed: schedule.missedRunPolicy === 'run',
204
- });
205
- const action = decision.accepted ? decision.actions[0]?.id : undefined;
206
- if (action === 'schedule.skipActiveAdvance') {
207
- schedule.lastScheduledSlot = slot.toISOString();
208
- schedule.nextRunAt = nextScheduleRun(schedule, now).toISOString();
209
- status = 'skipped-active';
210
- }
211
- else if (action === 'schedule.claimFromSlot' || action === 'schedule.claimFromNow') {
212
- schedule.lastScheduledSlot = slot.toISOString();
213
- schedule.currentRunSlot = slot.toISOString();
214
- schedule.nextRunAt = nextScheduleRun(schedule, action === 'schedule.claimFromSlot' ? slot : now).toISOString();
215
- status = 'completed';
216
- }
217
- if (status !== 'skipped-idempotent')
218
- current.updatedAt = now.toISOString();
219
- return current;
220
- });
221
- if (status !== 'skipped-idempotent')
222
- publishLocalCommit(result);
223
- return { job: result, status };
224
- }
225
- /** Persist completion only if this is still the authoritative claimed slot. */
226
- async finishScheduledSlot(jobId, slot, now) {
227
- if (!this.authority.mutateJob)
228
- throw new Error('Atomic SQLite schedule completion is required');
229
- const result = await this.authority.mutateJob(jobId, current => {
230
- if (current.schedule?.currentRunSlot !== slot)
231
- return current;
232
- if (!current.schedule.lastRunAt || Date.parse(current.schedule.lastRunAt) < now.getTime()) {
233
- current.schedule.lastRunAt = now.toISOString();
234
- current.updatedAt = now.toISOString();
235
- }
236
- return current;
237
- });
238
- publishLocalCommit(result);
239
- return result;
240
- }
241
184
  async appendPlan(plan) {
242
185
  await this.mutate(plan.jobId, job => {
243
186
  const existing = (job.plans ?? []).find(item => item.id === plan.id);
@@ -3,7 +3,7 @@ import { withLocalMergeLock } from './local-merge-lock.js';
3
3
  import { resolve } from 'node:path';
4
4
  import { existsSync, readFileSync } from 'node:fs';
5
5
  import { implementationComplete } from './runtime-status.js';
6
- import { completionGateSatisfied } from './scheduler.js';
6
+ import { completionGateSatisfied } from './task-transitions.js';
7
7
  function git(cwd, ...args) {
8
8
  return execFileSync('git', ['-C', cwd, ...args], {
9
9
  encoding: 'utf8', timeout: 30000, maxBuffer: 1024 * 1024,
@@ -28,7 +28,6 @@ export function localMergeFacts(job, headSha, now = Date.now) {
28
28
  return {
29
29
  jobActive: job.state === 'ACTIVE',
30
30
  autoMergeEnabled: job.autoMerge !== false,
31
- schedulePresent: Boolean(job.schedule),
32
31
  implementationComplete: implementationComplete(job),
33
32
  completionGateSatisfied: completionGateSatisfied(job),
34
33
  reviewSatisfied: job.reviewEnabled === false || job.review?.outcome === 'approved',
@@ -1,6 +1,6 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { eventFor } from './events.js';
3
- import { transitionJobEvent } from './scheduler.js';
3
+ import { transitionJobEvent } from './task-transitions.js';
4
4
  import { deliverLocalMerge, localMergeFacts } from './local-git-merge.js';
5
5
  import { errorMessage } from './observability.js';
6
6
  import { decideJobLifecycle } from './workflow/lifecycle.js';
package/dist/markers.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { currentJobMetadata } from './relay-job-snapshot-reads.js';
1
2
  export const JOB_MARKER = 'agents-relay:job:v1';
2
3
  export const TASK_MARKER = 'agents-relay:task:v1';
3
4
  export const PLAN_MARKER = 'agents-relay:plan:v1';
@@ -35,7 +36,7 @@ function contentBeforeDelimiter(body, heading, end) {
35
36
  }
36
37
  function jobMetadata(job) {
37
38
  const { objective: _objective, description: _description, tasks: _tasks, plans: _plans, ...metadata } = job;
38
- return metadata;
39
+ return currentJobMetadata(metadata);
39
40
  }
40
41
  export function renderJobBody(job) {
41
42
  const objective = job.objective?.trim() || '_No objective recorded._';
@@ -85,11 +86,9 @@ export function parseJob(body) {
85
86
  const bodyObjective = markerEnd >= 0 ? body.slice(markerEnd + 3).trim() || undefined : undefined;
86
87
  const objective = job.objective ?? job.description ?? contentBeforeDelimiter(body, '\n## Objective\n\n', JOB_OBJECTIVE_END) ?? markdownSection(body, '\n## Objective\n') ?? bodyObjective;
87
88
  const parsed = { ...job, objective };
88
- if (parsed.executionMode === undefined)
89
- return { ...parsed, executionMode: 'fixed' };
90
- if (parsed.executionMode !== 'fixed' && parsed.executionMode !== 'autonomous')
91
- return null;
92
- return parsed;
89
+ // Historical markers may carry mode/schedule; neither is live Job state.
90
+ const { executionMode: _legacyMode, schedule: _legacySchedule, ...current } = parsed;
91
+ return current;
93
92
  }
94
93
  export function parseTask(body) {
95
94
  const task = parseMarker(body, TASK_MARKER);
@@ -7,8 +7,8 @@ export async function bindPlannedReviewHead(store, task, localHeadSha) {
7
7
  'Review the current immutable local source head as a PRE-MERGE approval gate.',
8
8
  'Distinguish code added by this implementation from pre-existing local baseline/seed files present before the Task started.',
9
9
  'Preserved baseline files are not automatically extra changes merely because they differ from main; still review them for real risks.',
10
- 'Do not require a remote PR MERGED, Job COMPLETED, or post-merge E2E evidence before review approval:',
11
- 'those events must follow approval and are verified by the independent E2E runner.',
10
+ 'Do not require a remote PR MERGED, Job COMPLETED, or post-merge validation before review approval:',
11
+ 'those events must follow approval and require their own verified durable receipts.',
12
12
  ].join(' ');
13
13
  if (!task.input.includes(rule))
14
14
  task.input += '\n\n' + rule;
@@ -8,8 +8,8 @@ import { samePlannerChildDefinition } from './planner-child.js';
8
8
  import { dispatchJobLifecycleSync } from './workflow/lifecycle.js';
9
9
  import { withExecutionBoundary } from './reconciler-runtime.js';
10
10
  import { storySizeTimeoutMs } from './types.js';
11
- import { tryTransitionTaskEvent } from './scheduler.js';
12
- import { applyPlannerHistoryCleanup, forceRestateConverged } from './force-restate-validation.js';
11
+ import { tryTransitionTaskEvent } from './task-transitions.js';
12
+ import { applyPlannerHistoryCleanup, forceRestateConverged, objectiveSatisfiedForReview } from './force-restate-validation.js';
13
13
  import { decideForceRestateValidation } from './workflow/force-restate-validation-v2.js';
14
14
  import { workerCandidates } from './worker-routing.js';
15
15
  import { createHash } from 'node:crypto';
@@ -268,6 +268,7 @@ export async function finishPlanner(ports, jobId, planId, executionId, error, ra
268
268
  historyConsistencyValid: history.valid,
269
269
  normalHistoryMutationAbsent: !candidatePlan.forceRestate && historyCleanup.length === 0,
270
270
  forwardConvergenceValid: forwardValid,
271
+ objectiveSatisfied: !plan.forceRestate && objectiveSatisfiedForReview(candidate),
271
272
  });
272
273
  if (!decision.accepted) {
273
274
  throw new PlannerApplyValidationError('forward_convergence', [`Plan Apply graph rejected the candidate: ${decision.reason}.`]);
package/dist/planner.js CHANGED
@@ -1,6 +1,5 @@
1
1
  import { buildReferenceFirstPrompt, renderAgentPrompt } from './agent-prompt-builder.js';
2
2
  import { readFile } from 'node:fs/promises';
3
- import { parseTestInjection } from './e2e-injection.js';
4
3
  import { fileURLToPath } from 'node:url';
5
4
  import { dirname, join, resolve as resolvePath } from 'node:path';
6
5
  import { storySizeTimeoutMs, taskOverallTimeoutMs } from './types.js';
@@ -31,7 +30,6 @@ function plannerTrigger(job, manual = false) {
31
30
  const decision = dispatchPlannerTrigger({
32
31
  manualPlan: manual && !hasUnblock,
33
32
  hasUnblock,
34
- scheduledRun: Boolean(job.schedule?.currentRunSlot),
35
33
  initialPlan: (job.plans?.length ?? 0) === 0 && !job.tasks.some(task => task.kind === 'planner'),
36
34
  });
37
35
  if (!decision.accepted)
@@ -57,8 +55,6 @@ function plannerTrigger(job, manual = false) {
57
55
  throw new Error('Planner trigger graph selected unblock without unblock facts');
58
56
  return { source: 'unblock', taskIds: cycle.taskIds, reason: cycle.reason, facts: cycle.facts, blockedHeadShas: cycle.baselineHeadShas, currentHeadSha: cycle.currentHeadSha, unblockSource: cycle.source };
59
57
  }
60
- if (source === 'schedule')
61
- return { source: 'schedule' };
62
58
  if (source === 'initial')
63
59
  return { source: 'initial' };
64
60
  return { source: 'continuation' };
@@ -118,14 +114,14 @@ export function materializePlannerChild(job, planner, spec, executionContext) {
118
114
  throw new Error(`Planner resolution for ${spec.id} must target one unresolved durable Task`);
119
115
  }
120
116
  const reviewerThread = spec.agentName === 'reviewer' ? prepareReviewerThreadDelivery(job, { id: spec.id, agentName: spec.agentName, reviewHeadSha }) : undefined, implementationThread = (spec.agentName === 'implementation' || spec.agentName == null) && job.implementationThreadId ? job.implementationThreadId : undefined, threadOwned = Boolean(reviewerThread || implementationThread);
121
- return createTaskEntity(job, { jobId: job.id, id: spec.id, title: spec.agentName === 'reviewer' ? 'Review' : spec.title, kind: 'work', priority: spec.priority ?? job.priority, storySize: spec.storySize ?? 'M', projectName: spec.projectName, agentName: spec.agentName, agentGraph: spec.agentGraph, review: spec.review, parentTaskId: objectiveRoot?.id ?? spec.parentTaskId ?? null, dependencies: spec.dependencies ?? [], capabilities: spec.capabilities ?? [], workerRequirements: threadOwned ? chatgptThreadRequirements(spec.workerRequirements) : spec.workerRequirements, testInjection: spec.testInjection, adapter: threadOwned ? 'chatgpt' : (spec.adapter ?? 'codex'), chatgptThreadDelivery: reviewerThread, input: spec.input, output: spec.output, continuation: spec.continuation ?? null, continuationDeliveredAt: null, attempt: 0, maxAttempts: spec.maxAttempts ?? 3, leaseOwner: null, leaseExpiresAt: null, executionId: null, threadId: null, result: null, plannerResult: null, error: null, timeoutMs: spec.timeoutMs ?? taskOverallTimeoutMs(spec.storySize ?? 'M'), createdAt: now, updatedAt: now, planningDecisionId: decisionId, planningSource: source, planningRound: round, reviewHeadSha, resolution: resolution ? { ...resolution, state: 'proposed', owner: 'planner', updatedAt: now } : undefined });
117
+ return createTaskEntity(job, { jobId: job.id, id: spec.id, title: spec.agentName === 'reviewer' ? 'Review' : spec.title, kind: 'work', priority: spec.priority ?? job.priority, storySize: spec.storySize ?? 'M', projectName: spec.projectName, agentName: spec.agentName, agentGraph: spec.agentGraph, review: spec.review, parentTaskId: objectiveRoot?.id ?? spec.parentTaskId ?? null, dependencies: spec.dependencies ?? [], capabilities: spec.capabilities ?? [], workerRequirements: threadOwned ? chatgptThreadRequirements(spec.workerRequirements) : spec.workerRequirements, adapter: threadOwned ? 'chatgpt' : (spec.adapter ?? 'codex'), chatgptThreadDelivery: reviewerThread, input: spec.input, output: spec.output, continuation: spec.continuation ?? null, continuationDeliveredAt: null, attempt: 0, maxAttempts: spec.maxAttempts ?? 3, leaseOwner: null, leaseExpiresAt: null, executionId: null, threadId: null, result: null, plannerResult: null, error: null, timeoutMs: spec.timeoutMs ?? taskOverallTimeoutMs(spec.storySize ?? 'M'), createdAt: now, updatedAt: now, planningDecisionId: decisionId, planningSource: source, planningRound: round, reviewHeadSha, resolution: resolution ? { ...resolution, state: 'proposed', owner: 'planner', updatedAt: now } : undefined });
122
118
  }
123
119
  export function planningSpec(task) {
124
120
  if (!task.output)
125
121
  throw new Error(`Initial task ${task.id} requires an output contract`);
126
122
  if (!['codex', 'chatgpt'].includes(task.adapter))
127
123
  throw new Error(`Initial task ${task.id} requires a supported worker adapter`);
128
- return { id: task.id, title: task.title, parentTaskId: task.parentTaskId, input: task.input, priority: task.priority, storySize: task.storySize, projectName: task.projectName, agentName: task.agentName, agentGraph: task.agentGraph, review: task.review, dependencies: task.dependencies, capabilities: task.capabilities, workerRequirements: task.workerRequirements, testInjection: task.testInjection, adapter: task.adapter, output: task.output ?? { kind: 'task_pr' }, routing: task.routing, continuation: task.continuation, maxAttempts: task.maxAttempts, timeoutMs: task.timeoutMs, resolution: task.resolution ? { ...task.resolution, state: undefined, owner: undefined, updatedAt: undefined } : undefined };
124
+ return { id: task.id, title: task.title, parentTaskId: task.parentTaskId, input: task.input, priority: task.priority, storySize: task.storySize, projectName: task.projectName, agentName: task.agentName, agentGraph: task.agentGraph, review: task.review, dependencies: task.dependencies, capabilities: task.capabilities, workerRequirements: task.workerRequirements, adapter: task.adapter, output: task.output ?? { kind: 'task_pr' }, routing: task.routing, continuation: task.continuation, maxAttempts: task.maxAttempts, timeoutMs: task.timeoutMs, resolution: task.resolution ? { ...task.resolution, state: undefined, owner: undefined, updatedAt: undefined } : undefined };
129
125
  }
130
126
  function record(value, field = 'result') {
131
127
  if (!value || typeof value !== 'object' || Array.isArray(value))
@@ -318,7 +314,6 @@ export function parsePlannerResult(value) {
318
314
  id, title: requiredPlannerTaskTitle(task.title, index), parentTaskId, input: task.input, priority, storySize,
319
315
  projectName: optionalString(task.projectName, 'projectName'), agentName: optionalString(task.agentName, 'agentName'), agentGraph, review,
320
316
  dependencies: stringArray(task.dependencies, 'dependencies'), capabilities: stringArray(task.capabilities, 'capabilities'), workerRequirements: requirements,
321
- testInjection: parseTestInjection(task.testInjection ?? task['--test-injection']) ?? optionalString(task.testInjection ?? task['--test-injection'], `next_tasks[${index}].--test-injection`),
322
317
  adapter: adapter, output: output, routing: route,
323
318
  continuation: task.continuation, maxAttempts, timeoutMs, resolution: resolutionProposal(task.resolution, `next_tasks[${index}].resolution`)
324
319
  };
package/dist/pool.js CHANGED
@@ -1,11 +1,10 @@
1
1
  import { listManagedGitHubJobs } from './store.js';
2
2
  import { eventFor } from './events.js';
3
- import { runScheduledBucketFromStore } from './scheduled-jobs.js';
4
- import { scheduleBucket } from './scheduler.js';
5
3
  import { ReconcileCoordinator } from './reconcile-coordinator.js';
6
4
  import { errorMessage, observability, withObservabilityCorrelation } from './observability.js';
7
5
  import { jobHasActiveExecution } from './job-execution-slot.js';
8
- import { unblockNeedsNewPlan } from './unblock.js';
6
+ import { pendingPlan } from './planner.js';
7
+ import { decidePlannerOrchestration, plannerOrchestrationFacts } from './workflow/planner-orchestration-v2.js';
9
8
  import { pendingTroubleshooterRetries } from './troubleshooter.js';
10
9
  const RANK = { P0: 0, P1: 1, P2: 2, P3: 3 };
11
10
  export function compareJobPriority(a, b) { return RANK[a.priority ?? 'P2'] - RANK[b.priority ?? 'P2'] || a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id); }
@@ -19,7 +18,9 @@ export function needsLifecycleReconciliation(job, now = Date.now()) {
19
18
  if (job.state === 'ACTIVE' && job.localMerge?.status === 'failed' &&
20
19
  (!job.localMerge.nextAttemptAt || Date.parse(job.localMerge.nextAttemptAt) <= now))
21
20
  return true;
22
- if (job.executionMode === 'autonomous' && unblockNeedsNewPlan(job))
21
+ // The Graph owns continuation eligibility, including restart recovery.
22
+ const planning = decidePlannerOrchestration(plannerOrchestrationFacts(job, Boolean(pendingPlan(job))));
23
+ if (planning.accepted && planning.actions.length > 0)
23
24
  return true;
24
25
  if (pendingTroubleshooterRetries(job).length > 0)
25
26
  return true;
@@ -44,7 +45,6 @@ export class RepositoryWorkerPool {
44
45
  pending = false;
45
46
  reconcileLocks = new Map();
46
47
  coordinator;
47
- scheduleSignalCoordinator;
48
48
  dispatchTail = Promise.resolve();
49
49
  watchedBus;
50
50
  constructor(concurrency, makeReconciler, localInventory, resolveLocalJob) {
@@ -61,7 +61,6 @@ export class RepositoryWorkerPool {
61
61
  });
62
62
  };
63
63
  this.coordinator = new ReconcileCoordinator((jobId, signals) => this.consumeSignals(jobId, signals), reportCoordinatorError);
64
- this.scheduleSignalCoordinator = new ReconcileCoordinator((jobId, signals) => this.consumeScheduleSignals(jobId, signals), reportCoordinatorError);
65
64
  }
66
65
  async localTarget(jobId) {
67
66
  return this.resolveLocalJob(jobId);
@@ -107,20 +106,6 @@ export class RepositoryWorkerPool {
107
106
  }
108
107
  return `event.${event.type}`;
109
108
  }
110
- async consumeScheduleSignals(jobId, signals) {
111
- const event = [...signals].reverse().find(item => item.event?.type === 'schedule.tick')?.event;
112
- const bucket = typeof event?.data?.bucket === 'string' ? event.data.bucket : null;
113
- const tickAt = new Date(typeof event?.data?.tickAt === 'string' ? event.data.tickAt : event?.timestamp ?? Date.now());
114
- if (!bucket || Number.isNaN(tickAt.getTime()))
115
- return;
116
- const target = await this.localTarget(jobId);
117
- if (!target)
118
- return;
119
- await runScheduledBucketFromStore(target.store, { jobId, bucket }, {
120
- reconcile: async () => this.reconcileLocalJob(jobId, 'schedule.tick'),
121
- idle: async () => { },
122
- }, tickAt);
123
- }
124
109
  async reconcileLocalJob(jobId, reason = 'local.request') {
125
110
  const target = await this.localTarget(jobId);
126
111
  if (!target)
@@ -164,7 +149,8 @@ export class RepositoryWorkerPool {
164
149
  const expiredRunning = job.tasks.some(task => ['RUNNING', 'QUEUED'].includes(task.state) && task.executionId !== null && task.leaseExpiresAt !== null && new Date(task.leaseExpiresAt).getTime() <= Date.now());
165
150
  if (!needsLifecycleReconciliation(job))
166
151
  continue;
167
- const allowance = Math.max(1, Math.min(capacity, ready || (expiredRunning ? 1 : 0) || 1));
152
+ // A Graph-selected Planner continuation does not reserve a Task worker slot.
153
+ const allowance = ready || expiredRunning ? Math.max(1, Math.min(capacity, ready || 1)) : 0;
168
154
  try {
169
155
  await this.reconcileExclusive(job.id, async () => {
170
156
  await this.makeReconciler(store, allowance).reconcile(job.id);
@@ -227,10 +213,6 @@ export class RepositoryWorkerPool {
227
213
  const unsubscribe = await bus.subscribeAll(async (event) => {
228
214
  const routedWebhook = event.type === 'github.webhook' && event.data?.coordinatorRouted === true;
229
215
  const ingressWebhook = event.type === 'github.webhook' && event.data?.coordinatorIngress === true && !routedWebhook;
230
- if (event.type === 'schedule.tick') {
231
- await this.scheduleSignalCoordinator.acceptTransport({ jobId: event.job_id, reason: this.signalReason(event), event });
232
- return;
233
- }
234
216
  if (ingressWebhook)
235
217
  return;
236
218
  if (routedWebhook)
@@ -254,17 +236,6 @@ export class RepositoryWorkerPool {
254
236
  this.watchedBus = undefined;
255
237
  };
256
238
  }
257
- async scheduleTickSignal(bus, job, now = new Date()) {
258
- if (!job.schedule)
259
- return;
260
- const event = eventFor(job.id, 'scheduler', null, 'schedule.tick', 'queued', `Schedule tick for ${job.schedule.nextRunAt}`, 'orchestrator', { bucket: scheduleBucket(new Date(job.schedule.nextRunAt)), tickAt: now.toISOString() }, { agent: 'relay', component: 'scheduler-timer' });
261
- // Use the same coordinator-owned schedule path whether transport is available or not.
262
- if (bus) {
263
- await bus.publish(event);
264
- return;
265
- }
266
- await this.scheduleSignalCoordinator.accept({ jobId: job.id, reason: this.signalReason(event), event });
267
- }
268
239
  async webhookSignal(_bus, _wake) {
269
240
  // Uncorrelated webhook wakeups are intentionally ignored. A caller must first
270
241
  // resolve the target from local cached state and then call webhookWake().
@@ -1,5 +1,5 @@
1
1
  import { storySizeTimeoutMs, taskOverallTimeoutMs } from './types.js';
2
- import { completionGateSatisfied, schedule, transitionTaskEvent, tryTransitionTaskEvent } from './scheduler.js';
2
+ import { completionGateSatisfied, refreshTaskDependencies, transitionTaskEvent, tryTransitionTaskEvent } from './task-transitions.js';
3
3
  import { implementationComplete, implementationTasks } from './runtime-status.js';
4
4
  import { eventFor } from './events.js';
5
5
  import { workerCandidates } from './worker-routing.js';
@@ -37,7 +37,6 @@ export class ReconcilerReviewBase extends ReconcilerRuntimeBase {
37
37
  const reviewTasks = job.tasks.filter(task => task.agentName === 'reviewer' && task.reviewHeadSha === headSha);
38
38
  const currentReviewTask = job.review?.taskId ? job.tasks.find(task => task.id === job.review?.taskId) : undefined;
39
39
  return {
40
- unscheduled: !job.schedule,
41
40
  reviewEnabled: job.reviewEnabled !== false,
42
41
  jobActive: job.state === 'ACTIVE' || job.projectionRecovery?.status === 'repairing',
43
42
  implementationComplete: implementationComplete(job),
@@ -149,7 +148,7 @@ export class ReconcilerReviewBase extends ReconcilerRuntimeBase {
149
148
  'Review the complete PR diff at this exact immutable head SHA, not only the dependency Tasks.',
150
149
  'Use the selected reviewer agent contract as the authoritative review protocol.',
151
150
  'This is a PRE-MERGE artifact approval gate: inspect exact PR head, requirements, tests and genuine blocking defects.',
152
- 'Do not block approval because automatic merge, Job COMPLETED, or post-merge E2E evidence has not occurred yet.',
151
+ 'Do not block approval because automatic merge, Job COMPLETED, or post-merge validation has not occurred yet.',
153
152
  'Do not treat historical failed attempts as current artifact defects; distinguish preserved baseline files from implementation.',
154
153
  ].join('\n\n');
155
154
  const task = createTaskEntity(job, {
@@ -213,7 +212,7 @@ export class ReconcilerReviewBase extends ReconcilerRuntimeBase {
213
212
  job.review = { outcome, headSha, taskId: reviewTaskId, evidence: null, updatedAt: now };
214
213
  job.updatedAt = now;
215
214
  await this.store.saveJob(job);
216
- const ready = schedule(await this.store.load(job.id));
215
+ const ready = refreshTaskDependencies(await this.store.load(job.id));
217
216
  await this.store.saveJob(ready);
218
217
  }
219
218
  async selectReviewer(implementationTasks, requirementsOverride) {
@@ -290,7 +289,7 @@ export class ReconcilerReviewBase extends ReconcilerRuntimeBase {
290
289
  job: {
291
290
  id: job.id, title: job.title, objective: job.objective ?? null,
292
291
  description: job.description ?? null, priority: job.priority ?? null,
293
- executionMode: job.executionMode, repository: job.repository,
292
+ repository: job.repository,
294
293
  prNumber: this.externalReviewPrNumber(job), state: job.state,
295
294
  },
296
295
  task: {
@@ -5,12 +5,11 @@ import { drainProductionWorkflowOutbox } from './reconciler-workflow-outbox.js';
5
5
  import { cancelPausedGenerations } from './reconciler-pause.js';
6
6
  import { taskCommentContext } from './task-comments.js';
7
7
  import { cancelActiveTasks } from './reconciler-cancellation.js';
8
- import { authorizedInjection } from './e2e-injection.js';
9
8
  import { implementationComplete } from './runtime-status.js';
10
9
  import { bindPlannedReviewHead } from './planned-review-head.js';
11
10
  import { randomUUID } from 'node:crypto';
12
11
  import { storySizeTimeoutMs, chatGptStartAckTimeoutMs } from './types.js';
13
- import { dependencyReady, schedule, transitionTaskEvent } from './scheduler.js';
12
+ import { dependencyReady, refreshTaskDependencies, transitionTaskEvent } from './task-transitions.js';
14
13
  import { applyTaskRetryAction } from './task-actions.js';
15
14
  import { LaunchFailure, StartAckTimeout } from './adapters.js';
16
15
  import { eventFor } from './events.js';
@@ -57,7 +56,7 @@ export class Reconciler extends ReconcilerReviewBase {
57
56
  this.workflowEffects.register(new CallbackWorkflowEffectHandler('effect.cleanupJobWorktree', async (context) => cleanupMergedJobWorktree(await this.store.load(context.jobId), this.options.projectPathForJob), context => `effect.cleanupJobWorktree:${context.jobId}`));
58
57
  }
59
58
  async dispatchExecutableTasks(jobId) {
60
- const scheduled = schedule(await this.store.load(jobId));
59
+ const scheduled = refreshTaskDependencies(await this.store.load(jobId));
61
60
  if (scheduled.pause)
62
61
  return scheduled;
63
62
  for (const task of scheduled.tasks.filter(item => item.state === 'SUCCEEDED' && (item.continuation || scheduled.continuation) && !item.continuationDeliveredAt)) {
@@ -78,7 +77,7 @@ export class Reconciler extends ReconcilerReviewBase {
78
77
  await this.applyTaskEffectOutcomes(scheduled, task, outcomes);
79
78
  }, async () => { await this.store.saveTask(task); });
80
79
  }
81
- return schedule(await this.store.load(jobId));
80
+ return refreshTaskDependencies(await this.store.load(jobId));
82
81
  }
83
82
  async reconcile(jobId) {
84
83
  if (this.reconciling)
@@ -174,8 +173,7 @@ export class Reconciler extends ReconcilerReviewBase {
174
173
  }
175
174
  const beforeSchedule = await this.store.load(jobId);
176
175
  const taskStatesBeforeSchedule = new Map(beforeSchedule.tasks.map(task => [task.id, task.state]));
177
- const scheduleBefore = JSON.stringify(beforeSchedule.schedule ?? null);
178
- let scheduled = schedule(beforeSchedule);
176
+ let scheduled = refreshTaskDependencies(beforeSchedule);
179
177
  for (const task of scheduled.tasks)
180
178
  if (task.executionId && ['QUEUED', 'RUNNING'].includes(task.state))
181
179
  this.armInactivityWatchdog(task, task.executionId);
@@ -183,19 +181,17 @@ export class Reconciler extends ReconcilerReviewBase {
183
181
  if (taskStatesBeforeSchedule.get(task.id) !== task.state)
184
182
  await this.store.saveTask(task);
185
183
  }
186
- if (scheduled.state !== previousState || JSON.stringify(scheduled.schedule ?? null) !== scheduleBefore)
184
+ if (scheduled.state !== previousState)
187
185
  await this.store.saveJob(scheduled);
188
186
  {
189
187
  await this.ensureFinalReview(scheduled, headSha);
190
- scheduled = schedule(await this.store.load(jobId));
191
- }
192
- if (scheduled.executionMode === 'autonomous' || (scheduled.plans ?? []).some(plan => plan.forceRestate && plan.status === 'FAILED')) {
193
- scheduled = schedule(await this.maybeCreatePlanner(scheduled));
188
+ scheduled = refreshTaskDependencies(await this.store.load(jobId));
194
189
  }
190
+ scheduled = refreshTaskDependencies(await this.maybeCreatePlanner(scheduled));
195
191
  if (scheduled.state !== previousState || scheduled.updatedAt !== job.updatedAt)
196
192
  await this.store.saveJob(scheduled);
197
193
  scheduled = await this.dispatchExecutableTasks(jobId);
198
- let finalState = schedule(await this.store.load(jobId));
194
+ let finalState = refreshTaskDependencies(await this.store.load(jobId));
199
195
  if (finalState.updatedAt !== scheduled.updatedAt)
200
196
  await this.store.saveJob(finalState);
201
197
  await this.ensureFinalReview(finalState, headSha);
@@ -277,6 +273,9 @@ export class Reconciler extends ReconcilerReviewBase {
277
273
  return executeManualPlan(this.store, jobId, guidance, this.options.plannerTimeoutMs ?? storySizeTimeoutMs('M'), (job, plan) => this.launchPlanner(job, plan, true));
278
274
  }
279
275
  async maybeCreatePlanner(job) {
276
+ // Terminal Jobs can repair projections and re-review, but must not reopen planning.
277
+ if (job.state !== 'ACTIVE')
278
+ return job;
280
279
  const pending = pendingPlan(job);
281
280
  const recoverable = pending && !this.plannerLive.has(pending.id)
282
281
  && (!pending.executionId || pending.leaseOwner !== this.options.owner || !pending.leaseExpiresAt || Date.parse(pending.leaseExpiresAt) <= (this.options.now ?? new Date()).getTime())
@@ -470,31 +469,7 @@ export class Reconciler extends ReconcilerReviewBase {
470
469
  const startAckSetup = task.adapter === 'chatgpt' ? await this.startAck(job.id, task.id, sourceId, Date.now(), this.options.startAckTimeoutMs ?? chatGptStartAckTimeoutMs(task.timeoutMs)) : null;
471
470
  const closePolicy = task.chatgptTabClosePolicy ?? this.options.chatgptTabClosePolicy ?? adapter.tabClosePolicy ?? 'after-start';
472
471
  const terminalWait = task.adapter === 'chatgpt' && closePolicy === 'after-terminal' ? this.terminalWait(task.id, durableExecutionId, startedAt - 1) : null;
473
- const declaration = authorizedInjection(job.repository, task.testInjection);
474
- if (task.testInjection && !declaration) {
475
- await this.failLaunch(job.id, task.id, durableExecutionId, `E2E test injection refused for repository ${job.repository}; configure E2E_REPO on relayd`, 'delivery');
476
- return;
477
- }
478
- const workerCheckpoint = declaration?.method === 'worker.codex.launch' || declaration?.method === 'browserWorkspace.command';
479
- const testInjection = workerCheckpoint && ((task.adapter === 'codex' && declaration.method === 'worker.codex.launch') || (task.adapter === 'chatgpt' && declaration.method === 'browserWorkspace.command')) ? declaration : undefined;
480
- if (workerCheckpoint && !testInjection) {
481
- await this.failLaunch(job.id, task.id, durableExecutionId, `E2E test injection ${declaration.method} is incompatible with adapter ${task.adapter}`, 'delivery');
482
- return;
483
- }
484
- if (testInjection) {
485
- const durable = (await this.store.load(job.id)).tasks.find(item => item.id === task.id);
486
- if (!durable || durable.executionId !== durableExecutionId)
487
- return;
488
- delete durable.testInjection;
489
- durable.updatedAt = new Date(Math.max(Date.now(), Date.parse(durable.updatedAt) + 1)).toISOString();
490
- await this.store.saveTask(durable);
491
- const consumed = (await this.store.load(job.id)).tasks.find(item => item.id === task.id);
492
- if (!consumed || consumed.executionId !== durableExecutionId || consumed.testInjection)
493
- throw new Error(`E2E injection consumption was not durably committed for ${task.id}`);
494
- delete task.testInjection;
495
- }
496
472
  const launchContext = {
497
- ...(testInjection ? { testInjection } : {}),
498
473
  ...(startAckSetup ? { waitForWorkerStart: startAckSetup.ack } : {}),
499
474
  ...(terminalWait ? { waitForTerminal: terminalWait.promise } : {}),
500
475
  executionContext,
@@ -1,13 +1,13 @@
1
- import { normalizeLegacyJobPrNumber } from './job-pr-number-migration.js';
1
+ import { currentJobMetadata, normalizeRelayJob } from './relay-job-snapshot-reads.js';
2
2
  export function createJobIfAbsent(db, job) {
3
3
  db.exec('BEGIN IMMEDIATE');
4
4
  try {
5
5
  const row = db.prepare('SELECT json FROM job_snapshot WHERE job_id=?').get(job.id);
6
6
  if (row?.json) {
7
7
  db.exec('COMMIT');
8
- return normalizeLegacyJobPrNumber(JSON.parse(row.json));
8
+ return normalizeRelayJob(row.json);
9
9
  }
10
- db.prepare('INSERT INTO job_snapshot(job_id,json,updated_at) VALUES(?,?,?)').run(job.id, JSON.stringify(job), new Date().toISOString());
10
+ db.prepare('INSERT INTO job_snapshot(job_id,json,updated_at) VALUES(?,?,?)').run(job.id, JSON.stringify(currentJobMetadata(job)), new Date().toISOString());
11
11
  db.exec('COMMIT');
12
12
  return structuredClone(job);
13
13
  }
@@ -1,8 +1,12 @@
1
1
  import { normalizeLegacyTaskLifecycle } from './types.js';
2
2
  import { normalizeLegacyJobPrNumber } from './job-pr-number-migration.js';
3
+ export function currentJobMetadata(job) {
4
+ const { executionMode: _mode, schedule: _schedule, ...current } = job;
5
+ return current;
6
+ }
3
7
  export function normalizeRelayJob(json) {
4
- const job = normalizeLegacyJobPrNumber(JSON.parse(json));
5
- job.tasks = job.tasks.map(t => normalizeLegacyTaskLifecycle(t, job.executionMode));
8
+ const job = currentJobMetadata(normalizeLegacyJobPrNumber(JSON.parse(json)));
9
+ job.tasks = job.tasks.map(t => normalizeLegacyTaskLifecycle(t));
6
10
  job.plans = (job.plans ?? []).map(plan => {
7
11
  const legacy = plan;
8
12
  if (plan.forceRestate === undefined && legacy.forceCleanHistory === true)
@@ -1,4 +1,4 @@
1
- import { loadRelayJob, listRelayJobs } from './relay-job-snapshot-reads.js';
1
+ import { currentJobMetadata, loadRelayJob, listRelayJobs } from './relay-job-snapshot-reads.js';
2
2
  import { SQLiteWorkflowOperations } from './relay-workflow-operations.js';
3
3
  import { preserveTaskComments } from './task-comments.js';
4
4
  import { DatabaseSync } from 'node:sqlite';
@@ -242,7 +242,7 @@ export class SQLiteRelayStore {
242
242
  this.db.prepare(`
243
243
  INSERT INTO job_snapshot(job_id,json,updated_at) VALUES(?,?,?)
244
244
  ON CONFLICT(job_id) DO UPDATE SET json=excluded.json, updated_at=excluded.updated_at
245
- `).run(job.id, JSON.stringify(job), now);
245
+ `).run(job.id, JSON.stringify(currentJobMetadata(job)), now);
246
246
  }
247
247
  async loadAgents() {
248
248
  const observed = this.scopeJobId
@@ -52,7 +52,7 @@ export class SQLiteWorkflowOperations {
52
52
  if (!row?.json)
53
53
  throw new Error(`Unknown Job ${jobId}`);
54
54
  const current = normalizeRelayJob(row.json);
55
- current.tasks = current.tasks.map(task => normalizeLegacyTaskLifecycle(task, current.executionMode));
55
+ current.tasks = current.tasks.map(task => normalizeLegacyTaskLifecycle(task));
56
56
  const previous = structuredClone(current);
57
57
  const next = mutation(current);
58
58
  if (next.id !== jobId)
@@ -121,7 +121,7 @@ export class SQLiteWorkflowOperations {
121
121
  if (!row?.json)
122
122
  throw new Error(`Local authoritative Job ${jobId} not found`);
123
123
  const current = normalizeRelayJob(row.json);
124
- current.tasks = current.tasks.map(task => normalizeLegacyTaskLifecycle(task, current.executionMode));
124
+ current.tasks = current.tasks.map(task => normalizeLegacyTaskLifecycle(task));
125
125
  const previous = structuredClone(current);
126
126
  const before = JSON.stringify(current);
127
127
  const next = mutation(current);
@@ -8,7 +8,7 @@ import { classifyFailedTask, supersedeCancelledTask } from './task-actions.js';
8
8
  import { requestTroubleshooterEscalation, resumeTroubleshooter } from './task-recovery.js';
9
9
  import { adoptExistingPullRequestJob } from './cli-job-create.js';
10
10
  export function createWorkspaceControl(deps) {
11
- const { client, trusted, noGitHub, repositories, refreshAll, requireLocalJobBinding, makeReconciler, concurrency, pool, bus, isEffectivelyBlocked, unblockAutonomousTasks, unblockTask, publishTaskUnblocked, dispatchTaskAction, registerBlockerDependency, discoverAgents, ensureManagedGitHubJob } = deps;
11
+ const { client, trusted, noGitHub, repositories, refreshAll, requireLocalJobBinding, makeReconciler, concurrency, pool, bus, isEffectivelyBlocked, unblockTask, publishTaskUnblocked, dispatchTaskAction, registerBlockerDependency, discoverAgents, ensureManagedGitHubJob } = deps;
12
12
  const workspaceControl = async (body) => {
13
13
  const command = typeof body.command === 'string' ? body.command : '';
14
14
  const jobId = typeof body.jobId === 'string' ? body.jobId : '';
@@ -19,6 +19,8 @@ export function createWorkspaceControl(deps) {
19
19
  const options = (body.options && typeof body.options === 'object' ? body.options : {});
20
20
  if (!repository)
21
21
  throw new Error('job.create requires repository');
22
+ if ('executionMode' in options || 'schedule' in options)
23
+ throw new Error('Job execution modes and schedules are unsupported');
22
24
  const result = await ensureManagedGitHubJob(client, repository, trusted, {
23
25
  id: typeof options.id === 'string' && options.id ? options.id : undefined,
24
26
  idempotencyKey: typeof options.idempotencyKey === 'string' ? options.idempotencyKey : undefined,
@@ -26,8 +28,6 @@ export function createWorkspaceControl(deps) {
26
28
  objective: typeof options.objective === 'string' ? options.objective : undefined,
27
29
  projectPath: typeof options.projectPath === 'string' ? options.projectPath : undefined,
28
30
  priority: options.priority,
29
- executionMode: options.executionMode,
30
- schedule: options.schedule,
31
31
  tag: options.tag,
32
32
  reviewEnabled: typeof options.reviewEnabled === 'boolean' ? options.reviewEnabled : undefined,
33
33
  continuation: options.continuation,
@@ -44,7 +44,7 @@ export function createWorkspaceControl(deps) {
44
44
  const current = result.job;
45
45
  const existingPlan = current.plans?.at(-1);
46
46
  if (existingPlan)
47
- return { repository, pr: result.pr.number, job: current.id, executionMode: current.executionMode, state: current.state, initialPlan: existingPlan.id, reused: true };
47
+ return { repository, pr: result.pr.number, job: current.id, state: current.state, initialPlan: existingPlan.id, reused: true };
48
48
  const unpublished = new InMemoryStore(current);
49
49
  const reconciler = makeReconciler(unpublished, concurrency);
50
50
  try {
@@ -60,13 +60,13 @@ export function createWorkspaceControl(deps) {
60
60
  unpublishedStore.relay.close();
61
61
  }
62
62
  await pool.reconcileLocalJob(result.job.id, 'control.job.create.initial-plan');
63
- return { repository, pr: committed.prNumber, job: committed.id, executionMode: committed.executionMode, state: committed.state, initialPlan: committed.plans?.at(-1)?.id };
63
+ return { repository, pr: committed.prNumber, job: committed.id, state: committed.state, initialPlan: committed.plans?.at(-1)?.id };
64
64
  }
65
65
  finally {
66
66
  await reconciler.close();
67
67
  }
68
68
  }
69
- return { repository, pr: result.pr.number, job: result.job.id, executionMode: result.job.executionMode, state: result.job.state };
69
+ return { repository, pr: result.pr.number, job: result.job.id, state: result.job.state };
70
70
  }
71
71
  if (command === 'job.adopt') {
72
72
  const repository = typeof body.repository === 'string' ? body.repository : '';
@@ -91,7 +91,9 @@ export function createWorkspaceControl(deps) {
91
91
  const job = await store.load(jobId);
92
92
  if (command === 'job.update') {
93
93
  const patch = (body.patch && typeof body.patch === 'object' ? body.patch : {});
94
- const mutable = ['title', 'objective', 'projectPath', 'priority', 'executionMode', 'schedule', 'tag', 'autoMerge', 'reviewEnabled', 'reviewerThreadId', 'creatorThreadId', 'implementationThreadId', 'continuation', 'localMerge'];
94
+ if ('executionMode' in patch || 'schedule' in patch)
95
+ throw new Error('Job execution modes and schedules are unsupported');
96
+ const mutable = ['title', 'objective', 'projectPath', 'priority', 'tag', 'autoMerge', 'reviewEnabled', 'reviewerThreadId', 'creatorThreadId', 'implementationThreadId', 'continuation', 'localMerge'];
95
97
  for (const key of mutable)
96
98
  if (Object.prototype.hasOwnProperty.call(patch, key)) {
97
99
  const value = patch[key];
@@ -26,10 +26,6 @@ export function mergeOperationalInventory(current, discovered) {
26
26
  return [...merged.values()];
27
27
  }
28
28
  const PR_REFRESH_COALESCE_MS = 1000;
29
- export const SCHEDULE_HEARTBEAT_MS = 600_000;
30
- export function dueScheduledOverviews(overviews, now = new Date()) {
31
- return overviews.filter(item => item.job.state === 'ACTIVE' && item.job.executionMode === 'autonomous' && item.job.schedule && Date.parse(item.job.schedule.nextRunAt) <= now.getTime());
32
- }
33
29
  export function shouldCoalescePullRequestRefresh(completedAt, now = Date.now(), force = false) {
34
30
  return !force && completedAt !== null && now >= completedAt && now - completedAt < PR_REFRESH_COALESCE_MS;
35
31
  }