agents-relay 1.0.28 → 1.0.30

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
@@ -38,6 +38,8 @@ autonomous runtime uses the bundled `skills/agents-relay/agents/planner.agent.md
38
38
  snapshot as JSON on stdin and must return `{ "objective_status": "in_progress"|"satisfied", "assessment": "...", "next_tasks": [...] }`.
39
39
  Planner-created tasks use stable IDs and the existing parent/subtask tree, so
40
40
  retries and daemon restarts do not create duplicate work.
41
+ Task producers must follow `skills/agents-relay/contracts/task-job-contract.md`; that shared skill contract defines story size, lifecycle events, review, and auto-merge semantics.
42
+ All task producers use the same story-size contract: `S`≈5 minutes, `M`≈15 minutes, and `L`≈45 minutes of normal-reasoning execution, with `M` as the default. `high` reasoning doubles that overall window. ChatGPT Browser Worker start-ack timeout is derived as one third of the effective overall timeout, clamped between 1 and 5 minutes. Orchestrators and planners should choose `storySize`, not raw timeout milliseconds; `--timeout` remains only as an explicit compatibility/debug override.
41
43
 
42
44
  job create first resolves an existing open PR with the same head/base, then creates it only when needed. Re-running it is idempotent: it reuses the PR and maintains exactly one trusted agents-relay:job:v1 marker.
43
45
  Use `--start` to create/reuse the job and submit its initial task in one invocation. Start mode requires explicit `--adapter`, `--model`, `--output`, and non-empty `--input`; the task ID defaults to `initial`. Re-running with the same task definition reuses that task and never duplicates it. Omitting `--start` preserves create-only behavior, leaving an `OPEN` job with zero tasks until a caller submits work.
package/dist/cli.js CHANGED
@@ -99,7 +99,7 @@ Example:
99
99
  submit: `Queue a task for a managed job.
100
100
 
101
101
  Required: --repo OWNER/REPO, --pr NUMBER, --id JOB_ID, and --input TEXT.
102
- Required for worker tasks: --adapter codex|chatgpt, --model MODEL, and --output task-pr|file. File output also requires --output-path PATH.\nCommon: --task-id ID, --priority P0|P1|P2|P3, --max-attempts N. Model-backed execution requires --events nats in the running reconciler/daemon.
102
+ Required for worker tasks: --adapter codex|chatgpt, --model MODEL, and --output task-pr|file. File output also requires --output-path PATH.\nCommon: --task-id ID, --priority P0|P1|P2|P3, --story-size S|M|L, --max-attempts N. Story size defaults to M. Task producers follow the shared Task / Job Contract; prefer --story-size over raw --timeout. Model-backed execution requires --events nats in the running reconciler/daemon.
103
103
 
104
104
  Example:
105
105
  npx agents-relay submit --repo OWNER/REPO --pr 12 --id job-1 --task-id build --adapter codex --provider openai --model gpt-5-6 --output task-pr --input "Run the required tests and report the result through events."`,
@@ -440,7 +440,7 @@ async function runJobCommand(action, args) {
440
440
  console.log(JSON.stringify({ repository: repo, pr: result.pr.number, job: result.job.id, executionMode: result.job.executionMode, state: result.job.state, task: start ? arg(args, '--task-id', 'initial') : undefined }, null, 2));
441
441
  }
442
442
  export function runtimePlanner(args) { const plannerCommand = arg([...args], '--planner-command'); const modelRuntime = arg([...args], '--codex', 'codex'); return plannerCommand ? new CommandObjectivePlanner(plannerCommand) : new AgentObjectivePlanner(modelRuntime); }
443
- export function runtimeReviewer() { return { adapter: 'chatgpt', routing: { provider: 'chatgpt', model: 'account-defaults', decidedBy: 'fixed-chatgpt-reviewer', decidedAt: new Date().toISOString() }, startAckTimeoutMs: 120000 }; }
443
+ export function runtimeReviewer() { return { adapter: 'chatgpt', routing: { provider: 'chatgpt', model: 'account-defaults', decidedBy: 'fixed-chatgpt-reviewer', decidedAt: new Date().toISOString() } }; }
444
444
  export function runtime(store, args, bus) { const emit = (event) => { process.stderr.write(`${JSON.stringify(event)}\n`); }; const modelRuntime = arg(args, '--codex', 'codex'); const policy = chatGptTabClosePolicy(args); const planner = runtimePlanner(args); return new Reconciler(store, { owner: arg(args, '--owner', `cli-${process.pid}`), maxConcurrent: Number(arg(args, '--concurrency', '4')), leaseMs: Number(arg(args, '--lease-ms', '300000')), adapters: [new CodexAdapter(modelRuntime), new ChatGptAdapter({ tabClosePolicy: policy })], continuations: [new CodexThreadContinuation(modelRuntime), new CommandContinuation(), new WebhookContinuation()], planner, reviewer: runtimeReviewer(), eventBus: bus, emit, chatgptTabClosePolicy: policy }); }
445
445
  export async function createService(args) {
446
446
  const loaded = await storeFor(args);
package/dist/planner.js CHANGED
@@ -140,6 +140,7 @@ export class CommandObjectivePlanner {
140
140
  }
141
141
  }
142
142
  const DEFAULT_PLANNER_AGENT = join(dirname(fileURLToPath(import.meta.url)), '..', 'skills', 'agents-relay', 'agents', 'planner.agent.md');
143
+ const DEFAULT_TASK_JOB_CONTRACT = join(dirname(fileURLToPath(import.meta.url)), '..', 'skills', 'agents-relay', 'contracts', 'task-job-contract.md');
143
144
  function parsePlannerAgentMessage(line) {
144
145
  try {
145
146
  const value = JSON.parse(line);
@@ -162,7 +163,8 @@ export class AgentObjectivePlanner {
162
163
  }
163
164
  async plan(context, signal) {
164
165
  const instructions = await readFile(this.agentFile, 'utf8');
165
- const prompt = instructions.trim() + '\n\n## Durable planner input\n\n' + plannerInput(context);
166
+ const contract = await readFile(DEFAULT_TASK_JOB_CONTRACT, 'utf8');
167
+ const prompt = instructions.trim() + '\n\n## Shared Task / Job Contract\n\n' + contract.trim() + '\n\n## Durable planner input\n\n' + plannerInput(context);
166
168
  return await new Promise((resolve, reject) => {
167
169
  const child = spawn(this.command, ['exec', '--json', '--', prompt], { stdio: ['ignore', 'pipe', 'pipe'] });
168
170
  let stdoutBuffer = '';
@@ -1,5 +1,5 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { storySizeTimeoutMs, taskOverallTimeoutMs } from './types.js';
2
+ import { storySizeTimeoutMs, taskOverallTimeoutMs, chatGptStartAckTimeoutMs } from './types.js';
3
3
  import { schedule, transitionTask } from './scheduler.js';
4
4
  import { LaunchFailure, StartAckTimeout } from './adapters.js';
5
5
  import { eventFor } from './events.js';
@@ -71,7 +71,7 @@ export class Reconciler {
71
71
  }
72
72
  async handleEvent(event) {
73
73
  if (event.type === 'task.started') {
74
- this.markWorkerStarted(event);
74
+ await this.markWorkerStarted(event);
75
75
  return;
76
76
  }
77
77
  if (['task.completed', 'task.failed', 'task.blocked', 'task.cancelled'].includes(event.type)) {
@@ -343,7 +343,7 @@ export class Reconciler {
343
343
  const controller = new AbortController();
344
344
  let execution;
345
345
  const workerTask = { ...task, input: workerInput };
346
- const startAckSetup = task.adapter === 'chatgpt' ? await this.startAck(job.id, task.id, Date.now(), task.agentName === 'reviewer' ? this.options.reviewer?.startAckTimeoutMs : undefined) : null;
346
+ const startAckSetup = task.adapter === 'chatgpt' ? await this.startAck(job.id, task.id, Date.now(), this.options.startAckTimeoutMs ?? chatGptStartAckTimeoutMs(task.timeoutMs)) : null;
347
347
  const closePolicy = task.chatgptTabClosePolicy ?? this.options.chatgptTabClosePolicy ?? adapter.tabClosePolicy ?? 'after-start';
348
348
  const terminalWait = task.adapter === 'chatgpt' && closePolicy === 'after-terminal'
349
349
  ? this.terminalWait(task.id, durableExecutionId, startedAt - 1)
@@ -512,17 +512,44 @@ export class Reconciler {
512
512
  clearTimeout(graceTimer);
513
513
  this.terminalExitGraceTimers.delete(taskId);
514
514
  }
515
- markWorkerStarted(event) {
516
- const live = this.live.get(event.task_id);
517
- if (!live || event.job_id !== live.jobId)
518
- return;
515
+ async markWorkerStarted(event) {
519
516
  const sourceAgent = typeof event.source?.agent === 'string' ? event.source.agent : '';
520
517
  const sourceComponent = typeof event.source?.component === 'string' ? event.source.component : '';
521
518
  if (sourceAgent === 'relay' || sourceComponent === 'agents-relay')
522
519
  return;
523
- if (event.timestamp && new Date(event.timestamp).getTime() < live.launchBarrier)
520
+ const live = this.live.get(event.task_id);
521
+ if (live && event.job_id === live.jobId) {
522
+ if (event.timestamp && new Date(event.timestamp).getTime() < live.launchBarrier)
523
+ return;
524
+ live.workerStarted = true;
525
+ return;
526
+ }
527
+ const job = await this.store.load(event.job_id);
528
+ const task = job.tasks.find(item => item.id === event.task_id);
529
+ if (!task || task.state !== 'FAILED' || !this.isRecoverableWatchdogFailure(task))
524
530
  return;
525
- live.workerStarted = true;
531
+ if (event.timestamp && new Date(event.timestamp).getTime() < new Date(task.updatedAt).getTime())
532
+ return;
533
+ transitionTask(task, 'RUNNING');
534
+ task.failureReason = undefined;
535
+ task.error = null;
536
+ task.updatedAt = event.timestamp || new Date().toISOString();
537
+ await this.store.saveTask(task);
538
+ this.detach(this.reconcile(job.id), job.id, task.id, 'late worker-start reconciliation');
539
+ }
540
+ isRecoverableWatchdogFailure(task) {
541
+ if (['worker_start_timeout', 'terminal_event_timeout', 'worker_exit_before_start', 'worker_exit_without_terminal_event'].includes(task.failureReason ?? ''))
542
+ return true;
543
+ const error = task.error ?? '';
544
+ return error.includes('worker_start_timeout')
545
+ || error.includes('timed out waiting for the mandatory terminal worker event')
546
+ || error.includes('exited before publishing task.started')
547
+ || error.includes('exited after task.started without publishing a terminal task event');
548
+ }
549
+ isWorkerOwnedEvent(event) {
550
+ const sourceAgent = typeof event.source?.agent === 'string' ? event.source.agent : '';
551
+ const sourceComponent = typeof event.source?.component === 'string' ? event.source.component : '';
552
+ return sourceAgent !== 'relay' && sourceComponent !== 'agents-relay';
526
553
  }
527
554
  async failLaunch(jobId, taskId, executionId, message, phase = 'launch') {
528
555
  const claim = executionId ?? `${jobId}:${taskId}`;
@@ -592,7 +619,16 @@ export class Reconciler {
592
619
  async applyTerminalEvent(event) {
593
620
  const job = await this.store.load(event.job_id);
594
621
  const task = job.tasks.find(item => item.id === event.task_id);
595
- if (!task || !['codex', 'chatgpt'].includes(task.adapter) || task.state !== 'RUNNING')
622
+ if (!task || !['codex', 'chatgpt'].includes(task.adapter))
623
+ return;
624
+ if (task.state === 'FAILED' && this.isRecoverableWatchdogFailure(task) && this.isWorkerOwnedEvent(event)) {
625
+ if (event.timestamp && new Date(event.timestamp).getTime() < new Date(task.updatedAt).getTime())
626
+ return;
627
+ transitionTask(task, 'RUNNING');
628
+ task.failureReason = undefined;
629
+ task.error = null;
630
+ }
631
+ if (task.state !== 'RUNNING')
596
632
  return;
597
633
  const terminalWaiter = this.terminalWaiters.get(task.id);
598
634
  if (terminalWaiter && (!event.timestamp || new Date(event.timestamp).getTime() < terminalWaiter.launchBarrier))
@@ -606,12 +642,14 @@ export class Reconciler {
606
642
  if (event.type === 'task.completed') {
607
643
  task.result = { summary: event.message || `Task ${task.id} completed`, data: event.data };
608
644
  task.error = null;
645
+ task.failureReason = undefined;
609
646
  transitionTask(task, 'SUCCEEDED');
610
647
  await this.store.saveTask(task);
611
648
  await this.deliverContinuation(job, task);
612
649
  }
613
650
  else if (event.type === 'task.failed') {
614
651
  task.error = event.message || `Task ${task.id} failed`;
652
+ task.failureReason = typeof event.data?.reason === 'string' ? event.data.reason : undefined;
615
653
  transitionTask(task, 'FAILED');
616
654
  await this.store.saveTask(task);
617
655
  }
package/dist/types.js CHANGED
@@ -1,8 +1,9 @@
1
- export const STORY_SIZE_TIMEOUT_MS = { S: 120000, M: 300000, L: 900000 };
1
+ export const STORY_SIZE_TIMEOUT_MS = { S: 300000, M: 900000, L: 2700000 };
2
2
  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
+ export function chatGptStartAckTimeoutMs(overallTimeoutMs) { return Math.min(600000, Math.max(120000, Math.floor(overallTimeoutMs / 3))); }
4
5
  const jobTransitions = { OPEN: ['RUNNING', 'WAITING', 'BLOCKED', 'COMPLETED', 'CANCELLED'], RUNNING: ['OPEN', 'WAITING', 'BLOCKED', 'COMPLETED', 'FAILED', 'CANCELLED'], WAITING: ['RUNNING', 'BLOCKED', 'COMPLETED', 'CANCELLED'], BLOCKED: ['RUNNING', 'CANCELLED'], COMPLETED: ['RUNNING', 'CANCELLED'], FAILED: ['RUNNING', 'CANCELLED'], CANCELLED: ['RUNNING'] };
5
- 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', 'CANCELLED'], CANCELLED: [] };
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
7
  export function assertJobTransition(from, to) { if (from !== to && !jobTransitions[from].includes(to))
7
8
  throw new Error(`Invalid job transition ${from} -> ${to}`); }
8
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.28",
3
+ "version": "1.0.30",
4
4
  "description": "Durable async agent jobs coordinated through GitHub pull requests",
5
5
  "type": "module",
6
6
  "bin": {
@@ -7,6 +7,10 @@ description: Create and operate durable asynchronous agent jobs through GitHub P
7
7
 
8
8
  Use Agents Relay when an orchestrator needs durable delegated work that survives the current process.
9
9
 
10
+ ## Required shared contract
11
+
12
+ Before creating, planning, reviewing, or managing a job/task, read and follow `contracts/task-job-contract.md`. This is the canonical producer/runtime protocol for story size, lifecycle events, review, and auto-merge.
13
+
10
14
  ## Core task contract
11
15
 
12
16
  A managed child task is always a descriptive unit of work. It says what an agent must accomplish; it is never a shell command.
@@ -2,6 +2,8 @@
2
2
 
3
3
  You are the default objective planner for Agents Relay autonomous jobs.
4
4
 
5
+ Before producing tasks, read and follow `../contracts/task-job-contract.md`.
6
+
5
7
  Inspect the durable objective and task tree, then decide whether the objective is satisfied or which descriptive agent tasks should run next.
6
8
 
7
9
  ## Rules
@@ -12,21 +14,11 @@ Inspect the durable objective and task tree, then decide whether the objective i
12
14
  - Preserve parent/subtask causality and stable IDs.
13
15
  - A child task describes what an agent must accomplish. Never emit a shell command as a worker task.
14
16
  - Every child task must use adapter `codex` or `chatgpt`.
15
- - Every child task must declare exactly one output:
16
- - `{"kind":"task_pr"}`, or
17
- - `{"kind":"file","path":"..."}`.
17
+ - Every child task must declare exactly one output: `task_pr` or `file`.
18
18
  - Include routing metadata for every model-backed child task.
19
- - Do not call an execution agent "codex"; agent identity describes responsibility, while the adapter describes runtime.
20
- - If a child task includes `priority`, emit the canonical string `P0`, `P1`, `P2`, or `P3` exactly. Do not emit numeric priorities, lowercase aliases, or labels such as `high`/`medium`/`low`.
19
+ - Every child task must include `storySize` according to the shared Task / Job Contract. Use `M` when uncertain.
21
20
  - Return no prose outside the JSON result.
22
21
 
23
22
  ## Output
24
23
 
25
- Return exactly one JSON object with:
26
- - objective_status: "in_progress" or "satisfied"
27
- - assessment: a short evidence-based assessment
28
- - next_tasks: an array of typed task objects
29
-
30
- Each next task contains: id, input, adapter, output, routing, and may also contain priority, projectName, agentName, dependencies, capabilities, maxAttempts, and timeoutMs.
31
-
32
- When the objective is satisfied, next_tasks must be empty.
24
+ Return exactly one JSON object with `objective_status`, `assessment`, and `next_tasks`. Each next task contains id, input, adapter, output, routing, storySize, and may also contain priority, projectName, agentName, dependencies, capabilities, maxAttempts, and timeoutMs. When the objective is satisfied, next_tasks must be empty.
@@ -0,0 +1,32 @@
1
+ # Agents Relay Task / Job Contract
2
+
3
+ This contract is the shared protocol for every component that creates, schedules, reviews, or executes Agents Relay jobs and tasks.
4
+
5
+ ## Task producers
6
+
7
+ Any task producer — orchestrator, planner, or nested task-producing agent — must follow this contract.
8
+ Task producers choose semantic task properties such as objective, dependencies, adapter/routing, priority, and `storySize`. They do not normally choose raw timeout milliseconds.
9
+
10
+ ## Story size
11
+
12
+ Every worker task has one of three story sizes:
13
+
14
+ - `S`: about 5 minutes of normal-reasoning execution.
15
+ - `M`: about 15 minutes of normal-reasoning execution. This is the default when size is absent or uncertain.
16
+ - `L`: about 45 minutes of normal-reasoning execution.
17
+
18
+ Relay derives the overall execution timeout from `storySize`. If `routing.reasoning` is `high`, Relay doubles the overall execution timeout. Other reasoning levels do not change it.
19
+
20
+ Raw `timeoutMs` is a compatibility/debug/hotfix override, not the normal task-production interface.
21
+
22
+ ## Worker lifecycle
23
+
24
+ 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.
25
+
26
+ ## Job and review lifecycle
27
+
28
+ A normal task never merges a PR. After all non-superseded work gates pass, Relay creates a SHA-bound final review task. The reviewer emits exactly one of `review.approved`, `review.changes_required`, or `review.blocked`. When `autoMerge` is enabled (default), Relay merges only after `review.approved`, pinned to the exact reviewed head SHA. A new task or new PR head makes earlier final-review evidence stale and requires a fresh review.
29
+
30
+ ## Fixed and autonomous jobs
31
+
32
+ `fixed` jobs execute the orchestrator-provided task set and then proceed to final review. `autonomous` jobs may ask the planner for additional task batches. Planner-produced tasks must obey this same contract, including explicit `storySize`.