agents-relay 1.0.53 → 1.0.55

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -23,12 +23,17 @@ npx agents-relay reconcile --repo OWNER/REPO --pr 12 --id job-1
23
23
  npx agents-relay job show --repo OWNER/REPO --pr 12 --id job-1
24
24
  ~~~
25
25
 
26
- Mutable queued/retryable task fields can be changed in place without replacing the durable task identity. `task update` rejects `RUNNING`, `SUCCEEDED`, and `CANCELLED` tasks so active execution and terminal history cannot be rewritten. For example:
26
+ Task readiness is explicit: `DRAFT` means the durable task exists but is not executable yet; `QUEUED` means Relay may execute it now; `RUNNING` means worker execution has started. Tasks created with dependencies start `DRAFT` and become `QUEUED` when those dependencies succeed. A dependency-free task created with `--draft` stays held until `task update --queue`. Once a task has run, external waiting is represented as `BLOCKED`, never by returning to `DRAFT`.
27
+
28
+ Mutable draft/queued/retryable task fields can be changed in place without replacing the durable task identity. `task update` rejects `RUNNING`, `SUCCEEDED`, and `CANCELLED` tasks so active execution and terminal history cannot be rewritten. For example:
27
29
 
28
30
  ~~~sh
29
31
  npx agents-relay task update --repo OWNER/REPO --pr 12 --id job-1 --task-id child \
30
32
  --adapter chatgpt --provider chatgpt --model gpt-5.6-sol --reasoning high \
31
33
  --capabilities model,chatgpt,browser-harness
34
+
35
+ # Explicitly release a held DRAFT task
36
+ npx agents-relay task update --repo OWNER/REPO --pr 12 --id job-1 --task-id child --queue
32
37
  ~~~
33
38
 
34
39
  Jobs default to `fixed`. `job create` persists only the durable job and never
package/dist/cli.js CHANGED
@@ -15,7 +15,7 @@ import { serveDashboard } from './dashboard.js';
15
15
  import { codexAndZaiUsageRegistry } from './usage.js';
16
16
  import { discoverAgents } from './registry.js';
17
17
  import { AgentObjectivePlanner, CommandObjectivePlanner } from './planner.js';
18
- import { extendRetryBudget, parseJobSchedule } from './scheduler.js';
18
+ import { extendRetryBudget, parseJobSchedule, transitionTask } from './scheduler.js';
19
19
  import { dueBuckets, processDueScheduledJobs, syncScheduleLabels } from './scheduled-jobs.js';
20
20
  import { resolveExecutionContext } from './execution-context.js';
21
21
  import { defaultWorkspaceRoot, resolveManagedProjectPath } from './workspace.js';
@@ -93,16 +93,16 @@ Usage:
93
93
  npx agents-relay task <create|update|show|list|retry|cancel> [options]
94
94
 
95
95
  Actions:
96
- create Create one executable Task under an existing Job
96
+ create Create one durable Task under an existing Job
97
97
  update Update mutable Task fields
98
98
  show Show one Task
99
99
  list List Tasks for a Job
100
100
  retry Retry a failed or blocked Task
101
101
  cancel Cancel a Task`,
102
- 'task create': `Create one executable Task under an existing Job.
102
+ 'task create': `Create one durable Task under an existing Job.
103
103
 
104
104
  Required: normal Job storage options plus --input TEXT and --output task-pr|file. Codex/ChatGPT tasks also require --model MODEL.
105
- Common: --task-id ID, --adapter codex|chatgpt|orchestrator, --priority P0|P1|P2|P3, --story-size S|M|L, --deps a,b, --parent ID.
105
+ Common: --task-id ID, --adapter codex|chatgpt|orchestrator, --priority P0|P1|P2|P3, --story-size S|M|L, --deps a,b, --parent ID, --draft.\nTasks with dependencies start DRAFT; tasks without dependencies start QUEUED unless --draft is given.
106
106
  Directly-created Tasks are not wrapped in a synthetic Plan. Planner-generated Tasks are created internally by Relay from planner output.
107
107
 
108
108
  Example:
@@ -110,7 +110,7 @@ Example:
110
110
  'task update': `Update mutable durable Task fields in place.
111
111
 
112
112
  Required: --task-id ID and the normal Job storage options.
113
- Mutable while not RUNNING/SUCCEEDED/CANCELLED: --adapter, --priority, --story-size, --input, --project, --agent, --parent, --deps, --capabilities, --timeout, --max-attempts, --provider, --model, --profile, --reasoning, --cwd, --chatgpt-project, --chatgpt-tab-close-policy.
113
+ Mutable while not RUNNING/SUCCEEDED/CANCELLED: --adapter, --priority, --story-size, --input, --project, --agent, --parent, --deps, --capabilities, --timeout, --max-attempts, --provider, --model, --profile, --reasoning, --cwd, --chatgpt-project, --chatgpt-tab-close-policy, --draft, --queue.
114
114
  Use --clear-routing to remove routing metadata.
115
115
 
116
116
  Example:
@@ -340,6 +340,7 @@ function workerTaskFromArgs(job, args, now) {
340
340
  const routing = adapter === 'orchestrator' ? undefined : { provider: arg(args, '--provider', 'openai'), model, profile: arg(args, '--profile') || undefined, reasoning: arg(args, '--reasoning') || undefined, cwd: arg(args, '--cwd') || undefined, projectId: arg(args, '--chatgpt-project') || undefined, decidedBy: 'cli', decidedAt: now };
341
341
  const closePolicy = hasArg(args, '--chatgpt-tab-close-policy') ? chatGptTabClosePolicy(args) : undefined;
342
342
  const size = storySize(args);
343
+ const dependencies = arg(args, '--deps').split(',').filter(Boolean);
343
344
  const timeoutMs = adapter === 'orchestrator' ? 0 : hasArg(args, '--timeout') ? integerArg(args, '--timeout', taskOverallTimeoutMs(size, routing?.reasoning)) : taskOverallTimeoutMs(size, routing?.reasoning);
344
345
  return {
345
346
  jobId: job.id,
@@ -349,7 +350,7 @@ function workerTaskFromArgs(job, args, now) {
349
350
  projectName: arg(args, '--project') || undefined,
350
351
  agentName: arg(args, '--agent') || undefined,
351
352
  parentTaskId: arg(args, '--parent') || null,
352
- dependencies: arg(args, '--deps').split(',').filter(Boolean),
353
+ dependencies,
353
354
  capabilities: arg(args, '--capabilities').split(',').filter(Boolean),
354
355
  adapter,
355
356
  chatgptTabClosePolicy: closePolicy,
@@ -359,7 +360,7 @@ function workerTaskFromArgs(job, args, now) {
359
360
  routing,
360
361
  continuation: continuation(args),
361
362
  continuationDeliveredAt: null,
362
- state: 'QUEUED',
363
+ state: hasArg(args, '--draft') || dependencies.length > 0 ? 'DRAFT' : 'QUEUED',
363
364
  attempt: 0,
364
365
  maxAttempts: Number(arg(args, '--max-attempts', '3')),
365
366
  leaseOwner: null,
@@ -390,8 +391,13 @@ async function runTaskCommand(action, args) {
390
391
  task.id = id;
391
392
  await store.appendTask(task);
392
393
  const refreshed = await store.load(job.id);
393
- if (refreshed.state !== 'RUNNING') {
394
- refreshed.state = 'RUNNING';
394
+ const hasActive = refreshed.tasks.some(item => ['QUEUED', 'RUNNING'].includes(item.state));
395
+ const hasBlocked = refreshed.tasks.some(item => item.state === 'BLOCKED');
396
+ const hasDraft = refreshed.tasks.some(item => item.state === 'DRAFT');
397
+ const hasFailed = refreshed.tasks.some(item => item.state === 'FAILED');
398
+ const nextState = hasActive ? 'RUNNING' : hasBlocked ? 'BLOCKED' : hasDraft ? 'WAITING' : hasFailed ? 'FAILED' : refreshed.state;
399
+ if (refreshed.state !== nextState) {
400
+ refreshed.state = nextState;
395
401
  refreshed.updatedAt = now;
396
402
  await store.saveJob(refreshed);
397
403
  }
@@ -411,7 +417,7 @@ async function runTaskCommand(action, args) {
411
417
  if (action === 'retry' || action === 'cancel') {
412
418
  if (action === 'retry')
413
419
  task.maxAttempts = extendRetryBudget(task);
414
- task.state = action === 'retry' ? 'READY' : 'CANCELLED';
420
+ task.state = action === 'retry' ? 'QUEUED' : 'CANCELLED';
415
421
  task.error = action === 'retry' ? null : 'Cancelled by user';
416
422
  task.updatedAt = new Date().toISOString();
417
423
  await store.saveTask(task);
@@ -456,6 +462,16 @@ async function runTaskCommand(action, args) {
456
462
  task.chatgptTabClosePolicy = chatGptTabClosePolicy(args);
457
463
  task.timeoutMs = integerArg(args, '--timeout', task.timeoutMs);
458
464
  task.maxAttempts = integerArg(args, '--max-attempts', task.maxAttempts);
465
+ if (hasArg(args, '--draft') && hasArg(args, '--queue'))
466
+ throw new Error('--draft and --queue are mutually exclusive');
467
+ if (hasArg(args, '--draft'))
468
+ transitionTask(task, 'DRAFT');
469
+ if (hasArg(args, '--queue')) {
470
+ const taskMap = new Map(job.tasks.map(item => [item.id, item]));
471
+ if (!task.dependencies.every(dep => taskMap.get(dep)?.state === 'SUCCEEDED'))
472
+ throw new Error('Task dependencies must succeed before queueing');
473
+ transitionTask(task, 'QUEUED');
474
+ }
459
475
  if (hasArg(args, '--max-attempts') && task.maxAttempts < task.attempt)
460
476
  throw new Error('--max-attempts cannot be less than the current attempt count');
461
477
  const routingFlags = ['--provider', '--model', '--profile', '--reasoning', '--cwd', '--chatgpt-project'];
@@ -508,7 +524,7 @@ async function runJobCommand(action, args) {
508
524
  throw new Error(usage);
509
525
  const repo = repository(args);
510
526
  if (action === 'create') {
511
- const taskOnlyFlags = ['--start', '--task-id', '--input', '--adapter', '--provider', '--model', '--profile', '--reasoning', '--story-size', '--project', '--agent', '--parent', '--deps', '--capabilities', '--attachments', '--max-attempts', '--timeout', '--cwd', '--chatgpt-project', '--chatgpt-tab-close-policy', '--output', '--output-path'];
527
+ const taskOnlyFlags = ['--start', '--task-id', '--input', '--adapter', '--provider', '--model', '--profile', '--reasoning', '--story-size', '--project', '--agent', '--parent', '--deps', '--capabilities', '--attachments', '--max-attempts', '--timeout', '--cwd', '--chatgpt-project', '--chatgpt-tab-close-policy', '--output', '--output-path', '--draft', '--queue'];
512
528
  const invalid = taskOnlyFlags.find(flag => hasArg(args, flag));
513
529
  if (invalid)
514
530
  throw new Error(`job create does not accept task execution option ${invalid}; create the job first, then let the orchestrator create the initial plan/task batch`);
package/dist/dashboard.js CHANGED
@@ -95,7 +95,7 @@ export const dashboardHtml = `<!doctype html>
95
95
  .job-meta span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
96
96
  .status-pill,.badge { display:inline-flex; align-items:center; border-radius:999px; padding:.06rem .42rem; min-height:1.35rem; font-size:.68rem; font-weight:800; }
97
97
  .status-pill.ok,.badge.RUNNING,.badge.SUCCEEDED,.badge.COMPLETED { background:#e5f3e9; color:var(--ok); }
98
- .status-pill.warn,.badge.BLOCKED,.badge.WAITING,.badge.QUEUED,.badge.READY,.badge.OPEN { background:#fff1d2; color:var(--warn); }
98
+ .status-pill.warn,.badge.BLOCKED,.badge.DRAFT,.badge.QUEUED,.badge.WAITING,.badge.OPEN { background:#fff1d2; color:var(--warn); }
99
99
  .status-pill.bad,.badge.FAILED { background:#ffe8e8; color:var(--bad); }
100
100
  .status-pill.neutral,.badge.CANCELLED { background:#edf0f4; color:var(--muted); }
101
101
  .badge.DRAFT { background:#eef2ff; color:#3f51b5; }
@@ -220,7 +220,7 @@ function markdownField(label,input){const box=element('div','detail-group');box.
220
220
  function rememberSelection(){if(selectedJob)sessionStorage.setItem('agents-relay:selected-job',selectedJob);else sessionStorage.removeItem('agents-relay:selected-job');if(selectedTaskId)sessionStorage.setItem('agents-relay:selected-task',selectedTaskId);else sessionStorage.removeItem('agents-relay:selected-task');}
221
221
  function selected(){return jobs.find(job=>job.id===selectedJob)||jobs[0]||null;}
222
222
  function counts(tasks){const out={};for(const task of Array.isArray(tasks)?tasks:[])out[task.state]=(out[task.state]||0)+1;return out;}
223
- function compactCounts(input){const order=['RUNNING','BLOCKED','FAILED','QUEUED','READY','WAITING','SUCCEEDED','CANCELLED'];return order.filter(state=>input[state]).map(state=>input[state]+' '+state).join(', ')||'No tasks';}
223
+ function compactCounts(input){const order=['RUNNING','BLOCKED','FAILED','QUEUED','DRAFT','SUCCEEDED','CANCELLED'];return order.filter(state=>input[state]).map(state=>input[state]+' '+state).join(', ')||'No tasks';}
224
224
  function awaitingInitialTask(job){return job?.state==='OPEN'&&Array.isArray(job?.tasks)&&job.tasks.length===0;}
225
225
  function relativeTime(timestamp){if(!timestamp)return '—';const parsed=Date.parse(timestamp);if(!Number.isFinite(parsed))return value(timestamp);const seconds=Math.max(0,Math.round((Date.now()-parsed)/1000));if(seconds<60)return 'just now';if(seconds<3600)return Math.floor(seconds/60)+'m ago';if(seconds<86400)return Math.floor(seconds/3600)+'h ago';return Math.floor(seconds/86400)+'d ago';}
226
226
  function safePrUrl(repository,prNumber){if(typeof repository!=='string'||!Number.isInteger(prNumber)||prNumber<=0)return null;const parts=repository.split('/');if(parts.length!==2)return null;const [owner,repo]=parts;if(!owner||!repo||owner==='.'||owner==='..'||repo==='.'||repo==='..')return null;if(!/^[A-Za-z0-9-]+$/.test(owner)||!/^[A-Za-z0-9._-]+$/.test(repo))return null;return 'https://github.com/'+owner+'/'+repo+'/pull/'+prNumber;}
package/dist/pool.js CHANGED
@@ -7,7 +7,7 @@ export function needsLifecycleReconciliation(job, now = Date.now()) {
7
7
  // Create-only Jobs stay OPEN until a caller explicitly creates work.
8
8
  if (job.tasks.length === 0)
9
9
  return false;
10
- if (job.tasks.some(task => ['READY', 'QUEUED', 'WAITING'].includes(task.state)))
10
+ if (job.tasks.some(task => task.state === 'QUEUED' || (task.state === 'DRAFT' && task.dependencies.length > 0)))
11
11
  return true;
12
12
  return job.tasks.some(task => task.state === 'RUNNING' && task.leaseExpiresAt !== null && new Date(task.leaseExpiresAt).getTime() <= now);
13
13
  }
@@ -48,7 +48,7 @@ export class RepositoryWorkerPool {
48
48
  if (capacity <= 0)
49
49
  break;
50
50
  const store = new GitHubStore(this.client, job.repository, job.prNumber, this.trustedAuthors);
51
- const ready = job.tasks.filter(task => ['READY', 'QUEUED', 'WAITING'].includes(task.state)).length;
51
+ const ready = job.tasks.filter(task => task.state === 'QUEUED' || (task.state === 'DRAFT' && task.dependencies.length > 0)).length;
52
52
  const expiredRunning = job.tasks.some(task => task.state === 'RUNNING' && task.leaseExpiresAt !== null && new Date(task.leaseExpiresAt).getTime() <= Date.now());
53
53
  if (!needsLifecycleReconciliation(job))
54
54
  continue;
@@ -1,6 +1,6 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { storySizeTimeoutMs, taskOverallTimeoutMs, chatGptStartAckTimeoutMs } from './types.js';
3
- import { schedule, transitionTask } from './scheduler.js';
3
+ import { dependencyReady, schedule, transitionTask } from './scheduler.js';
4
4
  import { LaunchFailure, StartAckTimeout } from './adapters.js';
5
5
  import { eventFor } from './events.js';
6
6
  import { parsePlannerResult, plannerInput } from './planner.js';
@@ -160,7 +160,7 @@ export class Reconciler {
160
160
  for (const task of scheduled.tasks.filter(item => item.state === 'SUCCEEDED' && (item.continuation || scheduled.continuation) && !item.continuationDeliveredAt))
161
161
  this.detach(this.deliverContinuation(scheduled, task), scheduled.id, task.id, 'continuation');
162
162
  const capacity = Math.max(0, (this.options.maxConcurrent ?? 4) - this.live.size - this.plannerLive.size);
163
- for (const task of scheduled.tasks.filter(t => t.state === 'READY').sort((a, b) => compareTaskPriority(a, b, scheduled.priority ?? 'P2')).slice(0, capacity)) {
163
+ for (const task of scheduled.tasks.filter(t => t.state === 'QUEUED' && dependencyReady(t, new Map(scheduled.tasks.map(item => [item.id, item])))).sort((a, b) => compareTaskPriority(a, b, scheduled.priority ?? 'P2')).slice(0, capacity)) {
164
164
  if (task.kind === 'planner')
165
165
  await this.launchPlanner(scheduled, task);
166
166
  else if (task.adapter === 'orchestrator') {
@@ -199,7 +199,7 @@ export class Reconciler {
199
199
  return;
200
200
  job.review = { ...job.review, outcome: 'stale', updatedAt: new Date().toISOString() };
201
201
  const staleTask = job.review.taskId ? job.tasks.find(task => task.id === job.review?.taskId) : undefined;
202
- if (staleTask && ['QUEUED', 'READY', 'RUNNING', 'WAITING'].includes(staleTask.state)) {
202
+ if (staleTask && ['DRAFT', 'QUEUED', 'RUNNING'].includes(staleTask.state)) {
203
203
  this.live.get(staleTask.id)?.execution.cancel();
204
204
  transitionTask(staleTask, 'CANCELLED');
205
205
  staleTask.error = 'Review evidence became stale after the PR head changed';
@@ -218,7 +218,7 @@ export class Reconciler {
218
218
  if (review?.headSha === headSha && ['started', 'approved', 'changes_required', 'blocked'].includes(review.outcome))
219
219
  return;
220
220
  const reviewTasks = job.tasks.filter(task => task.agentName === 'reviewer' && task.reviewHeadSha === headSha);
221
- if (reviewTasks.some(task => ['QUEUED', 'READY', 'RUNNING', 'WAITING'].includes(task.state)))
221
+ if (reviewTasks.some(task => ['DRAFT', 'QUEUED', 'RUNNING'].includes(task.state)))
222
222
  return;
223
223
  if (reviewTasks.some(task => task.state === 'SUCCEEDED' && review?.headSha === headSha && review.outcome === 'approved'))
224
224
  return;
@@ -235,7 +235,7 @@ export class Reconciler {
235
235
  await this.emit(eventFor(job.id, id, null, 'review.created', 'queued', `Final review task ${id} created for ${headSha} using ${reviewerRouting.reason}`, 'orchestrator', { headSha, agentName: 'reviewer', reviewerRouting: { workerId: reviewerRouting.selection.workerId, reason: reviewerRouting.reason, selectedBy: reviewerRouting.selection.selectedBy } }));
236
236
  const ready = schedule(await this.store.load(job.id));
237
237
  await this.store.saveJob(ready);
238
- const reviewTask = ready.tasks.find(item => item.id === id && item.state === 'READY');
238
+ const reviewTask = ready.tasks.find(item => item.id === id && item.state === 'QUEUED');
239
239
  if (reviewTask && (this.options.maxConcurrent ?? 4) > this.live.size + this.plannerLive.size)
240
240
  await this.launch(ready, reviewTask);
241
241
  }
@@ -270,7 +270,7 @@ export class Reconciler {
270
270
  async routeExecutableTasks(job) {
271
271
  const usage = this.options.usageSnapshotProvider ? await this.options.usageSnapshotProvider() : undefined;
272
272
  const candidates = workerCandidates(this.options.adapters, await this.store.listAgents(), usage);
273
- for (const task of job.tasks.filter(item => item.kind !== 'planner' && item.agentName !== 'reviewer' && item.adapter !== 'orchestrator' && ['QUEUED', 'READY', 'BLOCKED'].includes(item.state) && item.output && !item.workerSelection && (item.workerRequirements || item.planningSource === 'model-planner'))) {
273
+ for (const task of job.tasks.filter(item => item.kind !== 'planner' && item.agentName !== 'reviewer' && item.adapter !== 'orchestrator' && ['QUEUED', 'BLOCKED'].includes(item.state) && item.output && !item.workerSelection && (item.workerRequirements || item.planningSource === 'model-planner'))) {
274
274
  const requirements = task.workerRequirements ?? { capabilities: task.capabilities.length > 0 ? task.capabilities : ['model'] };
275
275
  const routed = selectWorker(candidates, requirements);
276
276
  task.workerSelection = routed.selection;
@@ -365,7 +365,7 @@ export class Reconciler {
365
365
  this.plannerLive.delete(task.id);
366
366
  }
367
367
  if (task.attempt < task.maxAttempts) {
368
- transitionTask(task, 'READY');
368
+ transitionTask(task, 'QUEUED');
369
369
  task.leaseOwner = null;
370
370
  task.leaseExpiresAt = null;
371
371
  await this.store.saveTask(task);
@@ -391,7 +391,7 @@ export class Reconciler {
391
391
  const slot = job.schedule?.currentRunSlot ? Date.parse(job.schedule.currentRunSlot) : Number.NaN;
392
392
  const scopedTasks = Number.isFinite(slot) ? job.tasks.filter(task => Date.parse(task.createdAt) >= slot) : job.tasks;
393
393
  const planners = scopedTasks.filter(task => task.kind === 'planner').sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
394
- if (planners.some(task => ['QUEUED', 'READY', 'RUNNING', 'WAITING'].includes(task.state)))
394
+ if (planners.some(task => ['DRAFT', 'QUEUED', 'RUNNING'].includes(task.state)))
395
395
  return job;
396
396
  if (scopedTasks.some(task => task.state === 'FAILED' || task.state === 'BLOCKED'))
397
397
  return job;
@@ -598,7 +598,7 @@ export class Reconciler {
598
598
  task.error = error.message;
599
599
  task.leaseOwner = null;
600
600
  task.leaseExpiresAt = null;
601
- transitionTask(task, task.attempt < task.maxAttempts ? 'READY' : 'FAILED');
601
+ transitionTask(task, task.attempt < task.maxAttempts ? 'QUEUED' : 'FAILED');
602
602
  await this.store.saveTask(task);
603
603
  await this.emit(eventFor(job.id, task.id, task.parentTaskId, task.state === 'FAILED' ? 'planner.failed' : 'planner.retry', task.state === 'FAILED' ? 'failed' : 'queued', `Planner ${task.id}: ${error.message}`, 'user'));
604
604
  this.detach(this.reconcile(jobId), jobId, task.id, 'planner retry reconciliation');
@@ -636,7 +636,7 @@ export class Reconciler {
636
636
  task.error = message;
637
637
  task.leaseOwner = null;
638
638
  task.leaseExpiresAt = null;
639
- transitionTask(task, task.attempt < task.maxAttempts ? 'READY' : 'FAILED');
639
+ transitionTask(task, task.attempt < task.maxAttempts ? 'QUEUED' : 'FAILED');
640
640
  await this.store.saveTask(task);
641
641
  await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'planner.failed', 'failed', `Planner ${task.id}: ${message}`, 'user'));
642
642
  }
@@ -645,7 +645,7 @@ export class Reconciler {
645
645
  }
646
646
  plannerChild(job, planner, spec) {
647
647
  const now = new Date().toISOString();
648
- return { jobId: job.id, id: spec.id, kind: 'work', priority: spec.priority ?? job.priority, storySize: spec.storySize ?? 'M', projectName: spec.projectName, agentName: spec.agentName, parentTaskId: planner.id, dependencies: spec.dependencies ?? [], capabilities: spec.capabilities ?? [], workerRequirements: spec.workerRequirements, adapter: 'codex', input: spec.input, output: spec.output, continuation: spec.continuation ?? null, continuationDeliveredAt: null, state: 'QUEUED', 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: planner.id, planningSource: planner.planningSource ?? 'model-planner', planningRound: planner.planningRound ?? 1 };
648
+ return { jobId: job.id, id: spec.id, kind: 'work', priority: spec.priority ?? job.priority, storySize: spec.storySize ?? 'M', projectName: spec.projectName, agentName: spec.agentName, parentTaskId: planner.id, dependencies: spec.dependencies ?? [], capabilities: spec.capabilities ?? [], workerRequirements: spec.workerRequirements, adapter: 'codex', input: spec.input, output: spec.output, continuation: spec.continuation ?? null, continuationDeliveredAt: null, state: (spec.dependencies?.length ?? 0) > 0 ? 'DRAFT' : 'QUEUED', 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: planner.id, planningSource: planner.planningSource ?? 'model-planner', planningRound: planner.planningRound ?? 1 };
649
649
  }
650
650
  clearExecution(taskId, executionId) {
651
651
  const live = this.live.get(taskId);
@@ -147,7 +147,7 @@ export async function runScheduledBucket(client, repository, trustedAuthors, ref
147
147
  await syncScheduleLabels(client, repository, job);
148
148
  return { job, status: 'skipped-idempotent' };
149
149
  }
150
- const active = job.state === 'RUNNING' || job.tasks.some(task => ['READY', 'RUNNING'].includes(task.state));
150
+ const active = job.state === 'RUNNING' || job.tasks.some(task => ['QUEUED', 'RUNNING'].includes(task.state));
151
151
  if (active && schedule.concurrencyPolicy === 'skip') {
152
152
  schedule.lastScheduledSlot = slot.toISOString();
153
153
  schedule.nextRunAt = nextScheduleRun(schedule, now).toISOString();
package/dist/scheduler.js CHANGED
@@ -32,12 +32,17 @@ export function schedule(job) {
32
32
  }
33
33
  const map = new Map(job.tasks.map(t => [t.id, t]));
34
34
  for (const task of scopedTasks) {
35
- if (['QUEUED', 'WAITING'].includes(task.state) && dependencyReady(task, map))
36
- transitionTask(task, 'READY');
35
+ const ready = dependencyReady(task, map);
36
+ if (task.state === 'DRAFT' && task.dependencies.length > 0 && ready)
37
+ transitionTask(task, 'QUEUED');
38
+ else if (task.state === 'QUEUED' && !ready)
39
+ transitionTask(task, 'DRAFT');
40
+ else if (task.state === 'QUEUED' && ready && task.error !== null)
41
+ transitionTask(task, 'QUEUED');
37
42
  }
38
- const active = scopedTasks.some(t => ['READY', 'RUNNING'].includes(t.state));
43
+ const active = scopedTasks.some(t => ['QUEUED', 'RUNNING'].includes(t.state));
39
44
  const blocked = scopedTasks.some(t => t.state === 'BLOCKED');
40
- const waiting = scopedTasks.some(t => ['QUEUED', 'WAITING'].includes(t.state));
45
+ const waiting = scopedTasks.some(t => t.state === 'DRAFT');
41
46
  const failed = scopedTasks.some(t => t.state === 'FAILED');
42
47
  const cancelled = scopedTasks.every(t => t.state === 'CANCELLED');
43
48
  const next = active ? 'RUNNING' : failed ? 'FAILED' : blocked ? 'BLOCKED' : waiting ? 'WAITING' : cancelled ? 'CANCELLED' : completionGateSatisfied(job) ? 'COMPLETED' : 'WAITING';
@@ -65,7 +70,7 @@ export function extendRetryBudget(task, windowSize = 3) {
65
70
  const usedOrBudgeted = Math.max(task.maxAttempts, task.attempt);
66
71
  return Math.max(windowSize, (Math.floor(usedOrBudgeted / windowSize) + 1) * windowSize);
67
72
  }
68
- export function transitionTask(task, state) { assertTaskTransition(task.state, state); task.state = state; if (['READY', 'RUNNING', 'SUCCEEDED'].includes(state))
73
+ export function transitionTask(task, state) { assertTaskTransition(task.state, state); task.state = state; if (['QUEUED', 'RUNNING', 'SUCCEEDED'].includes(state))
69
74
  task.error = null; task.updatedAt = new Date().toISOString(); }
70
75
  export const SCHEDULE_RESOLUTION_MS = 600_000;
71
76
  export const RUN_BUCKET_LABEL_COUNT = 144;
package/dist/types.js CHANGED
@@ -3,7 +3,7 @@ export function storySizeTimeoutMs(size) { return STORY_SIZE_TIMEOUT_MS[size]; }
3
3
  export function taskOverallTimeoutMs(size, reasoning) { return storySizeTimeoutMs(size) * (reasoning?.toLowerCase() === 'high' ? 2 : 1); }
4
4
  export function chatGptStartAckTimeoutMs(overallTimeoutMs) { return Math.min(600000, Math.max(120000, Math.floor(overallTimeoutMs / 3))); }
5
5
  const jobTransitions = { OPEN: ['RUNNING', 'WAITING', 'BLOCKED', 'COMPLETED', 'CANCELLED'], RUNNING: ['OPEN', 'WAITING', 'BLOCKED', 'COMPLETED', 'FAILED', 'CANCELLED'], WAITING: ['RUNNING', 'BLOCKED', 'COMPLETED', 'CANCELLED'], BLOCKED: ['RUNNING', 'COMPLETED', 'CANCELLED'], COMPLETED: ['RUNNING', 'CANCELLED'], FAILED: ['RUNNING', 'COMPLETED', 'CANCELLED'], CANCELLED: ['RUNNING', 'COMPLETED'] };
6
- const taskTransitions = { QUEUED: ['READY', 'BLOCKED', 'CANCELLED'], READY: ['RUNNING', 'BLOCKED', 'FAILED', 'CANCELLED'], RUNNING: ['WAITING', 'BLOCKED', 'SUCCEEDED', 'FAILED', 'CANCELLED', 'READY'], WAITING: ['READY', 'BLOCKED', 'CANCELLED'], BLOCKED: ['READY', 'CANCELLED'], SUCCEEDED: [], FAILED: ['READY', 'RUNNING', 'CANCELLED'], CANCELLED: [] };
6
+ const taskTransitions = { DRAFT: ['QUEUED', 'BLOCKED', 'CANCELLED'], QUEUED: ['DRAFT', 'RUNNING', 'BLOCKED', 'FAILED', 'CANCELLED'], RUNNING: ['BLOCKED', 'SUCCEEDED', 'FAILED', 'CANCELLED', 'QUEUED'], BLOCKED: ['QUEUED', 'CANCELLED'], SUCCEEDED: [], FAILED: ['QUEUED', 'RUNNING', 'CANCELLED'], CANCELLED: [] };
7
7
  export function assertJobTransition(from, to) { if (from !== to && !jobTransitions[from].includes(to))
8
8
  throw new Error(`Invalid job transition ${from} -> ${to}`); }
9
9
  export function assertTaskTransition(from, to) { if (from !== to && !taskTransitions[from].includes(to))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-relay",
3
- "version": "1.0.53",
3
+ "version": "1.0.55",
4
4
  "description": "Durable async agent jobs coordinated through GitHub pull requests",
5
5
  "type": "module",
6
6
  "bin": {
@@ -30,6 +30,18 @@ Relay derives the overall execution timeout from `storySize`. If `routing.reason
30
30
 
31
31
  Raw `timeoutMs` is a compatibility/debug/hotfix override, not the normal task-production interface.
32
32
 
33
+ ## Task readiness states
34
+
35
+ Task readiness is explicit and must use these semantics:
36
+
37
+ - `DRAFT`: durable Task exists but is not executable now. A DRAFT Task with dependencies is promoted to `QUEUED` when all dependencies succeed. A DRAFT Task with no dependencies remains held until an explicit release to `QUEUED`.
38
+ - `QUEUED`: executable now; Relay may launch it immediately subject only to worker capacity/routing.
39
+ - `RUNNING`: worker execution has actually started.
40
+ - `BLOCKED`: execution cannot continue without an external resolution or intervention. Once a Task has entered `RUNNING`, it must not return to `DRAFT`; waiting discovered during execution is `BLOCKED`.
41
+ - `SUCCEEDED`, `FAILED`, and `CANCELLED` are terminal outcomes, except that retry explicitly requeues a failed/blocked Task.
42
+
43
+ Do not use `WAITING` as a Task state. `WAITING` remains a Job state for an active Job that is intentionally awaiting a future trigger, schedule slot, or condition.
44
+
33
45
  ## Worker lifecycle
34
46
 
35
47
  Relay owns process lifecycle. Workers own semantic task lifecycle. A worker must publish canonical `task.started` as its first execution action, then exactly one of `task.completed`, `task.failed`, `task.blocked`, or `task.cancelled`. Process launch/exit events do not substitute for worker semantic events.