agents-relay 2.0.107 → 2.0.108

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/dist/cli-help.js CHANGED
@@ -8,7 +8,7 @@ Usage:
8
8
  --no-github: SQLite/Graph-only mode. GitHub auth/API, PR creation, merge and fetch are disabled. Local completion is Graph-owned.
9
9
 
10
10
  Commands:
11
- job create|update|show|list|merge|unblock|cancel Manage SQLite-authoritative jobs with optional GitHub PRs
11
+ job create|update|show|list|merge|unblock|cancel|pause|resume Manage SQLite-authoritative jobs with optional GitHub PRs
12
12
  task create|update|show|list|complete|retry|unblock|cancel Manage durable tasks
13
13
  review request Request graph-owned review (optional thread override)
14
14
  plan Invoke the Planner for an existing managed Job
@@ -29,7 +29,7 @@ Use 'node dist/cli.js' only for explicit local development/demo mode.`,
29
29
  job: `Manage SQLite-authoritative durable jobs.
30
30
 
31
31
  Usage:
32
- npx agents-relay job <create|update|show|list|merge|unblock|cancel> [options]
32
+ npx agents-relay job <create|update|show|list|merge|unblock|cancel|pause|resume> [options]
33
33
 
34
34
  Actions:
35
35
  create Create a durable Job, optionally binding an existing unmanaged PR
@@ -39,6 +39,8 @@ Actions:
39
39
  merge Queue a retryable external PR merge; Graph completes the Job only after confirmation
40
40
  unblock Authoritatively invalidate current BLOCKED decisions and trigger replanning/resume
41
41
  cancel Close the backing PR and cancel the Job
42
+ pause Persistently stop dispatch and interrupt active generations; lifecycle state remains unchanged
43
+ resume Clear pause metadata and requeue interrupted work
42
44
 
43
45
  Common options:
44
46
  --repo OWNER/REPO GitHub repository
@@ -94,6 +96,12 @@ Required: --repo OWNER/REPO, --pr NUMBER, and --id JOB_ID. The current PR head S
94
96
  'job cancel': `Cancel the durable Job and close its backing pull request.
95
97
 
96
98
  Required: --repo OWNER/REPO, --pr NUMBER, and --id JOB_ID. Nonterminal Tasks are cancelled before the PR is closed.`,
99
+ 'job pause': `Pause a durable Job without inventing a PAUSED lifecycle state.
100
+
101
+ Required: --repo OWNER/REPO --id JOB_ID. Optional: --pr NUMBER. Running worker and Planner generations are interrupted durably and resume as uncompleted work.`,
102
+ 'job resume': `Resume a paused durable Job.
103
+
104
+ Required: --repo OWNER/REPO --id JOB_ID. Optional: --pr NUMBER. Interrupted work is requeued and normal reconciliation continues.`,
97
105
  escalation: `Run the canonical escalation workflow for a suspected human/external blocker.\n\nRequired: --intent TEXT|--intent-file PATH and --blocked-on TEXT|--blocked-on-file PATH. Optional: --context/--context-file, --on-needs-user/--on-needs-user-file, --cwd. The executable workflow/escalation.mmd graph invokes Neo escalation, then owns the resolved / failed / needs_user branch. Only needs_user selects the block-for-user path.`,
98
106
  status: `Show live aggregate runtime status from the running Agents Relay service.
99
107
 
package/dist/cli.js CHANGED
@@ -41,6 +41,7 @@ import { SERVICE_DEFAULTS, ServiceEventBus, arg, hasArg, textOption, integerArg,
41
41
  export { SERVICE_DEFAULTS, eventBus, executionMode, chatGptTabClosePolicy } from './cli-support.js';
42
42
  import { workerTaskFromArgs } from './cli-worker-task.js';
43
43
  import { taskExecutionContext } from './task-context.js';
44
+ import { pauseJob, resumeJob } from './job-pause.js';
44
45
  export { ensureManagedGitHubJob } from './cli-job-create.js';
45
46
  async function runManagedTaskCommand(action, args) {
46
47
  const jobId = arg(args, '--id');
@@ -363,7 +364,7 @@ async function runContextCommand(action, args) {
363
364
  console.log(JSON.stringify(context, null, 2));
364
365
  }
365
366
  async function runJobCommand(action, args) {
366
- if (!['create', 'adopt', 'update', 'show', 'list', 'merge', 'unblock', 'cancel'].includes(action))
367
+ if (!['create', 'adopt', 'update', 'show', 'list', 'merge', 'unblock', 'cancel', 'pause', 'resume'].includes(action))
367
368
  throw new Error(usage);
368
369
  const repo = repository(args);
369
370
  let createObjective;
@@ -400,6 +401,19 @@ async function runJobCommand(action, args) {
400
401
  const id = arg(args, '--id');
401
402
  if (!id && action !== 'create')
402
403
  throw new Error('--id is required for managed local Jobs');
404
+ if (action === 'pause' || action === 'resume') {
405
+ const target = await storeFor(args);
406
+ if (target.job.repository !== repo)
407
+ throw new Error('Local Job/PR binding mismatch');
408
+ const prValue = arg(args, '--pr');
409
+ if (prValue && target.job.prNumber !== Number(prValue))
410
+ throw new Error('Local Job/PR binding mismatch');
411
+ const updated = action === 'pause' ? await pauseJob(target.store, id) : await resumeJob(target.store, id);
412
+ await saveLocal(target.localFile, updated);
413
+ await withEventBus(args, bus => publishWake(bus, updated, 'job', `Job ${id} ${action} requested`));
414
+ console.log(JSON.stringify(updated, null, 2));
415
+ return;
416
+ }
403
417
  const prValue = arg(args, '--pr');
404
418
  const prNumber = prValue ? Number(prValue) : 0;
405
419
  if (prValue && (!Number.isInteger(prNumber) || (prNumber ?? 0) <= 0))
@@ -0,0 +1,75 @@
1
+ import { tryTransitionTaskEvent } from './scheduler.js';
2
+ export async function pauseJob(store, jobId) {
3
+ const mutate = (current) => {
4
+ if (['COMPLETED', 'CANCELLED'].includes(current.state))
5
+ throw new Error(`Cannot pause terminal Job ${current.id} (${current.state})`);
6
+ if (current.pause)
7
+ return current;
8
+ const now = new Date().toISOString();
9
+ current.pause = { generation: 1, requestedAt: now };
10
+ for (const task of current.tasks) {
11
+ if (task.kind === 'planner' || ['SUCCEEDED', 'FAILED', 'CANCELLED'].includes(task.state))
12
+ continue;
13
+ if (task.executionId)
14
+ task.pausedExecution = { id: task.executionId, state: task.state === 'RUNNING' ? 'RUNNING' : 'QUEUED', attempt: task.attempt, at: now };
15
+ if (task.state === 'RUNNING' && !tryTransitionTaskEvent(task, 'pause').accepted)
16
+ throw new Error(`Cannot pause running Task ${task.id}`);
17
+ task.executionId = null;
18
+ task.leaseOwner = null;
19
+ task.leaseExpiresAt = null;
20
+ task.updatedAt = now;
21
+ }
22
+ for (const plan of current.plans ?? []) {
23
+ if (plan.status !== 'PLANNING')
24
+ continue;
25
+ if (plan.executionId)
26
+ plan.pausedExecutionId = plan.executionId;
27
+ plan.executionId = null;
28
+ plan.leaseOwner = null;
29
+ plan.leaseExpiresAt = null;
30
+ plan.updatedAt = now;
31
+ }
32
+ current.updatedAt = now;
33
+ return current;
34
+ };
35
+ if (store.mutateJob)
36
+ return store.mutateJob(jobId, mutate);
37
+ const job = await store.load(jobId);
38
+ const updated = mutate(job);
39
+ await store.saveJob(updated);
40
+ for (const task of updated.tasks)
41
+ await store.saveTask(task);
42
+ for (const plan of updated.plans ?? [])
43
+ await store.savePlan(plan);
44
+ return updated;
45
+ }
46
+ export async function resumeJob(store, jobId) {
47
+ const mutate = (current) => {
48
+ if (!current.pause)
49
+ return current;
50
+ const now = new Date().toISOString();
51
+ delete current.pause;
52
+ for (const task of current.tasks) {
53
+ if (task.pausedExecution) {
54
+ if (task.attempt >= task.maxAttempts)
55
+ task.maxAttempts = task.attempt + 1;
56
+ delete task.pausedExecution;
57
+ task.updatedAt = now;
58
+ }
59
+ }
60
+ for (const plan of current.plans ?? [])
61
+ delete plan.pausedExecutionId;
62
+ current.updatedAt = now;
63
+ return current;
64
+ };
65
+ if (store.mutateJob)
66
+ return store.mutateJob(jobId, mutate);
67
+ const job = await store.load(jobId);
68
+ const updated = mutate(job);
69
+ await store.saveJob(updated);
70
+ for (const task of updated.tasks)
71
+ await store.saveTask(task);
72
+ for (const plan of updated.plans ?? [])
73
+ await store.savePlan(plan);
74
+ return updated;
75
+ }
@@ -5,6 +5,8 @@ export async function executeManualPlan(store, jobId, guidance, timeoutMs, launc
5
5
  let job = await store.load(jobId);
6
6
  if (['COMPLETED', 'CANCELLED'].includes(job.state))
7
7
  throw new Error(`Cannot plan terminal job ${job.id} (${job.state})`);
8
+ if (job.pause)
9
+ throw new Error(`Cannot plan paused Job ${job.id}; resume it first`);
8
10
  const active = pendingPlan(job);
9
11
  if (active && active.executionId)
10
12
  throw new Error(`Planning round ${active.id} is already PLANNING`);
@@ -13,6 +15,8 @@ export async function executeManualPlan(store, jobId, guidance, timeoutMs, launc
13
15
  const committed = await store.mutateJob(jobId, current => {
14
16
  if (['COMPLETED', 'CANCELLED'].includes(current.state))
15
17
  throw new Error(`Cannot plan terminal job ${current.id} (${current.state})`);
18
+ if (current.pause)
19
+ throw new Error(`Cannot plan paused Job ${current.id}; resume it first`);
16
20
  const concurrent = pendingPlan(current);
17
21
  if (concurrent) {
18
22
  if (concurrent.executionId)
@@ -281,6 +281,8 @@ export async function launchPlanExecution(ports, job, plan) {
281
281
  }
282
282
  const claimed = await withExecutionBoundary(job.id, '__job_execution_slot__', async () => {
283
283
  const freshJob = await ports.store.load(job.id);
284
+ if (freshJob.pause)
285
+ return null;
284
286
  const fresh = (freshJob.plans ?? []).find(item => item.id === plan.id);
285
287
  if (!fresh || fresh.status !== 'PLANNING')
286
288
  return null;
package/dist/pool.js CHANGED
@@ -10,6 +10,8 @@ const RANK = { P0: 0, P1: 1, P2: 2, P3: 3 };
10
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); }
11
11
  export function runnableManagedJobs(items) { return items.filter(item => item.job.state === 'ACTIVE').map(item => item.job).sort(compareJobPriority); }
12
12
  export function needsLifecycleReconciliation(job, now = Date.now()) {
13
+ if (job.pause)
14
+ return job.tasks.some(task => Boolean(task.pausedExecution)) || Boolean(job.plans?.some(plan => Boolean(plan.pausedExecutionId)));
13
15
  // DRAFT create-only Jobs stay dormant until a caller explicitly creates work.
14
16
  if (job.tasks.length === 0)
15
17
  return false;
@@ -0,0 +1,11 @@
1
+ /** Stop only generations already invalidated by the durable pause mutation. */
2
+ export function cancelPausedGenerations(job, cancelTask, cancelPlan) {
3
+ if (!job.pause)
4
+ return;
5
+ for (const task of job.tasks)
6
+ if (task.pausedExecution)
7
+ cancelTask(task.id, task.pausedExecution.id);
8
+ for (const plan of job.plans ?? [])
9
+ if (plan.pausedExecutionId)
10
+ cancelPlan(plan.id);
11
+ }
@@ -1,3 +1,4 @@
1
+ import { cancelPausedGenerations } from './reconciler-pause.js';
1
2
  import { taskCommentContext } from './task-comments.js';
2
3
  import { cancelActiveTasks } from './reconciler-cancellation.js';
3
4
  import { authorizedInjection } from './e2e-injection.js';
@@ -41,7 +42,7 @@ export class Reconciler extends ReconcilerReviewBase {
41
42
  this.workflowEffects.register(new CallbackWorkflowEffectHandler('effect.launchTask', async (context) => {
42
43
  const job = await this.store.load(context.jobId);
43
44
  const task = context.taskId ? job.tasks.find(item => item.id === context.taskId) : undefined;
44
- if (!task || task.state !== 'QUEUED' || task.executionId !== null)
45
+ if (!task || job.pause || task.state !== 'QUEUED' || task.executionId !== null)
45
46
  return;
46
47
  if (task.kind !== 'planner' && task.adapter !== 'orchestrator')
47
48
  await this.launch(job, task);
@@ -51,6 +52,8 @@ export class Reconciler extends ReconcilerReviewBase {
51
52
  }
52
53
  async dispatchExecutableTasks(jobId) {
53
54
  const scheduled = schedule(await this.store.load(jobId));
55
+ if (scheduled.pause)
56
+ return scheduled;
54
57
  for (const task of scheduled.tasks.filter(item => item.state === 'SUCCEEDED' && (item.continuation || scheduled.continuation) && !item.continuationDeliveredAt)) {
55
58
  this.detach(this.deliverContinuation(scheduled, task), scheduled.id, task.id, 'continuation');
56
59
  }
@@ -92,6 +95,14 @@ export class Reconciler extends ReconcilerReviewBase {
92
95
  }
93
96
  await this.invalidateStaleReview(job, headSha);
94
97
  const now = (this.options.now ?? new Date()).getTime();
98
+ if (job.pause) {
99
+ cancelPausedGenerations(job, (taskId, executionId) => this.cancelExecutionGeneration(job.id, taskId, executionId), planId => { const live = this.plannerLive.get(planId); if (live) {
100
+ clearTimeout(live.timer);
101
+ live.controller.abort();
102
+ this.plannerLive.delete(planId);
103
+ } });
104
+ return job;
105
+ }
95
106
  if (job.state !== 'ACTIVE')
96
107
  return job;
97
108
  await recoverExpiredExecutions(job, now, this.executionRecoveryPorts(job));
package/dist/scheduler.js CHANGED
@@ -17,6 +17,8 @@ export function completionGateSatisfied(job) {
17
17
  return (planSatisfied || legacySatisfied) && implementationTasks(job).every(task => task.state === 'SUCCEEDED');
18
18
  }
19
19
  export function schedule(job) {
20
+ if (job.pause)
21
+ return job;
20
22
  reconcileTaskDependencies(job);
21
23
  const scopedTasks = job.schedule?.currentRunSlot
22
24
  ? job.tasks.filter(task => Date.parse(task.createdAt) >= Date.parse(job.schedule?.currentRunSlot ?? ''))
@@ -19,6 +19,7 @@ export const WORKFLOW_EVENT_NAMES = [
19
19
  'pr.merged',
20
20
  'queue',
21
21
  'plan.requested',
22
+ 'pause',
22
23
  'planner.failed',
23
24
  'planner.unavailable',
24
25
  'reconcile',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-relay",
3
- "version": "2.0.107",
3
+ "version": "2.0.108",
4
4
  "description": "Durable async agent jobs coordinated through GitHub pull requests",
5
5
  "type": "module",
6
6
  "bin": {
@@ -178,6 +178,7 @@ leaseFailRunningMark --> leaseFailRunningClearLease
178
178
  leaseFailRunningClearLease --> leaseFailRunningClearExecution
179
179
  leaseFailRunningClearExecution --> failed
180
180
  running -->|on: task.cancelled| cancelled
181
+ running -->|on: pause| queued
181
182
  running -->|on: cancel| cancelled
182
183
 
183
184
  autonomousJob["guard: autonomousJob"]