agents-relay 2.0.55 → 2.0.57

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
@@ -247,6 +247,8 @@ GitHub PR state is authoritative for terminal lifecycle: a merged PR reconciles
247
247
 
248
248
  `adapter: orchestrator` is reserved for synchronous self-work by the exact orchestrator turn that creates the Task. Relay deliberately does not auto-start these Tasks during reconciliation. The creator must perform the work before ending the turn and terminalize the same Task with `npx agents-relay task complete --task-id <id> --summary <summary> ...`. If work should continue asynchronously or in a later turn, use a real worker adapter instead.
249
249
 
250
+ As a narrow recovery exception, reconciliation converts a `QUEUED` work Task that still has `adapter: orchestrator`, no execution identity, and is at least 30 minutes past `createdAt` into a normal ChatGPT-routable Task. This preserves the Task identity, intent, dependencies, output, lineage, priority, and story size while clearing stale routing state. `RUNNING` and terminal Tasks are never converted; the 30-minute rescue does not weaken the synchronous self-task contract for newly created work.
251
+
250
252
  For work that was completed outside the relay but must be represented truthfully in durable history, use `npx agents-relay record --task-id <id> --summary <summary> --commit <sha>`. Recorded tasks use the `orchestrator` adapter and terminal `SUCCEEDED` state; they do not pretend a shell/model worker executed the work. This is a repair/backfill mechanism—normal work should be created as a durable Task before execution. For managed jobs, the GitHub PR body is the canonical Job surface: hidden Job metadata marker plus the visible Markdown objective.
251
253
 
252
254
  ### Automatic task troubleshooting
@@ -1,37 +1,62 @@
1
1
  import { spawn } from 'node:child_process';
2
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
2
5
  import { textOption, arg } from './cli-support.js';
3
6
  import { runEscalationGraph } from './workflow/escalation.js';
4
- function runNeoEscalation(request) {
5
- const args = ['escalation', '--intent', request.intent, '--blocked-on', request.blockedOn];
6
- if (request.context)
7
- args.push('--context', request.context);
8
- if (request.onNeedsUser)
9
- args.push('--on-needs-user', request.onNeedsUser);
10
- if (request.cwd)
11
- args.push('--cwd', request.cwd);
12
- return new Promise((resolve, reject) => {
13
- const child = spawn('neo', args, { cwd: request.cwd ?? process.cwd(), stdio: ['ignore', 'pipe', 'pipe'] });
14
- let stdout = '';
15
- let stderr = '';
16
- child.stdout.setEncoding('utf8').on('data', chunk => { stdout += chunk; });
17
- child.stderr.setEncoding('utf8').on('data', chunk => { stderr += chunk; });
18
- child.on('error', reject);
19
- child.on('close', code => {
20
- let parsed;
21
- try {
22
- parsed = JSON.parse(stdout.trim());
23
- }
24
- catch {
25
- reject(new Error(`neo escalation returned invalid JSON (exit ${code ?? 'unknown'}): ${stderr || stdout}`));
26
- return;
27
- }
28
- if (!['resolved', 'failed', 'needs_user'].includes(parsed.status)) {
29
- reject(new Error(`neo escalation returned invalid status ${String(parsed.status)}`));
30
- return;
31
- }
32
- resolve(parsed);
7
+ export function neoEscalationArgs(handoffPath, policyPath, cwd) {
8
+ return [
9
+ 'escalation', '--handoff-file', handoffPath,
10
+ ...(policyPath ? ['--on-needs-user-file', policyPath] : []),
11
+ ...(cwd ? ['--cwd', cwd] : []),
12
+ ];
13
+ }
14
+ function handoffText(request) {
15
+ return [
16
+ '# Intent', request.intent.trim(),
17
+ '', '# Blocked on', request.blockedOn.trim(),
18
+ ...(request.context?.trim() ? ['', '# Context', request.context.trim()] : []),
19
+ '',
20
+ ].join('\n');
21
+ }
22
+ export async function runNeoEscalation(request) {
23
+ const tempRoot = await mkdtemp(join(tmpdir(), 'agents-relay-escalation-'));
24
+ try {
25
+ const handoffPath = join(tempRoot, 'handoff.md');
26
+ await writeFile(handoffPath, handoffText(request), { encoding: 'utf8', mode: 0o600 });
27
+ let policyPath;
28
+ if (request.onNeedsUser) {
29
+ policyPath = join(tempRoot, 'on-needs-user.md');
30
+ await writeFile(policyPath, request.onNeedsUser, { encoding: 'utf8', mode: 0o600 });
31
+ }
32
+ const args = neoEscalationArgs(handoffPath, policyPath, request.cwd);
33
+ return await new Promise((resolve, reject) => {
34
+ const child = spawn('neo', args, { cwd: request.cwd ?? process.cwd(), stdio: ['ignore', 'pipe', 'pipe'] });
35
+ let stdout = '';
36
+ let stderr = '';
37
+ child.stdout.setEncoding('utf8').on('data', chunk => { stdout += chunk; });
38
+ child.stderr.setEncoding('utf8').on('data', chunk => { stderr += chunk; });
39
+ child.on('error', reject);
40
+ child.on('close', code => {
41
+ let parsed;
42
+ try {
43
+ parsed = JSON.parse(stdout.trim());
44
+ }
45
+ catch {
46
+ reject(new Error(`neo escalation returned invalid JSON (exit ${code ?? 'unknown'}): ${stderr || stdout}`));
47
+ return;
48
+ }
49
+ if (!['resolved', 'failed', 'needs_user'].includes(parsed.status)) {
50
+ reject(new Error(`neo escalation returned invalid status ${String(parsed.status)}`));
51
+ return;
52
+ }
53
+ resolve(parsed);
54
+ });
33
55
  });
34
- });
56
+ }
57
+ finally {
58
+ await rm(tempRoot, { recursive: true, force: true });
59
+ }
35
60
  }
36
61
  export async function runEscalationCommand(args) {
37
62
  const intent = await textOption(args, '--intent', '--intent-file');
@@ -19,6 +19,7 @@ import { createReviewChairExecutionPlan } from './review-chair.js';
19
19
  import { taskTagWorkerPrompt } from './task-tag.js';
20
20
  import { recoverExpiredExecutions, recoverStartupOrphans as recoverStartupExecutionOrphans } from './execution-recovery.js';
21
21
  import { dispatchJobReconcile } from './workflow/job-reconcile-v2.js';
22
+ import { applyOrchestratorRescue } from './workflow/orchestrator-rescue.js';
22
23
  import { launchPlanExecution } from './planner-runtime.js';
23
24
  import { classifyFailure, errorMessage, observability, withObservabilityCorrelation } from './observability.js';
24
25
  const PRIORITY_RANK = { P0: 0, P1: 1, P2: 2, P3: 3 };
@@ -154,6 +155,13 @@ export class Reconciler extends ReconcilerReviewBase {
154
155
  if (job.state !== 'ACTIVE')
155
156
  return job;
156
157
  await recoverExpiredExecutions(job, now, this.executionRecoveryPorts(job));
158
+ for (const task of (await this.store.load(jobId)).tasks) {
159
+ const rescue = applyOrchestratorRescue(task, now);
160
+ if (!rescue.rescued)
161
+ continue;
162
+ await this.store.saveTask(task);
163
+ await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'task.orchestrator_rescued', 'queued', `Stale orchestrator task ${task.id} converted to ChatGPT execution`, 'orchestrator', { ageMs: rescue.ageMs, adapter: 'chatgpt' }, { agent: 'relay', component: 'agents-relay' }));
164
+ }
157
165
  await this.recoverPlannerChildren(job);
158
166
  for (const recovery of pendingTroubleshooterTasks(await this.store.load(jobId))) {
159
167
  await this.store.appendTask(recovery);
@@ -0,0 +1,38 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { dispatchWorkflowGraphSync } from './dispatcher.js';
3
+ import { GraphRuntime } from './graph-v2.js';
4
+ export const STALE_ORCHESTRATOR_TASK_MS = 30 * 60 * 1000;
5
+ const source = readFileSync(new URL('../../workflow/orchestrator-rescue.mmd', import.meta.url), 'utf8');
6
+ const boolGuard = (name) => (facts) => facts.durableFacts[name] === true;
7
+ export const orchestratorRescueV2 = new GraphRuntime(source, {
8
+ states: new Set(['idle', 'check', 'skip', 'rescued']),
9
+ guards: { rescuable: boolGuard('rescuable') },
10
+ actions: { 'task.rescueOrchestrator': { id: 'task.rescueOrchestrator' } },
11
+ events: { 'orchestrator.rescue.reconcile': { id: 'orchestrator.rescue.reconcile' } },
12
+ });
13
+ export function orchestratorRescueFacts(task, now) {
14
+ const createdAt = Date.parse(task.createdAt);
15
+ const oldEnough = Number.isFinite(createdAt) && now - createdAt >= STALE_ORCHESTRATOR_TASK_MS;
16
+ return { rescuable: task.kind !== 'planner' && task.adapter === 'orchestrator' && task.state === 'QUEUED' && task.executionId === null && oldEnough };
17
+ }
18
+ export function decideOrchestratorRescue(task, now) {
19
+ return dispatchWorkflowGraphSync(orchestratorRescueV2, {
20
+ initialState: 'idle', input: { transport: 'event', id: 'orchestrator.rescue.reconcile', payload: {} },
21
+ durableFacts: orchestratorRescueFacts(task, now), graphName: 'orchestrator-rescue',
22
+ isTransientState: state => state === 'check', maxSteps: 2,
23
+ });
24
+ }
25
+ export function applyOrchestratorRescue(task, now) {
26
+ const decision = decideOrchestratorRescue(task, now);
27
+ const rescued = decision.accepted && decision.actions.some(action => action.id === 'task.rescueOrchestrator');
28
+ const ageMs = now - Date.parse(task.createdAt);
29
+ if (!rescued)
30
+ return { rescued: false, ageMs };
31
+ task.adapter = 'chatgpt';
32
+ task.workerRequirements = { capabilities: [...task.capabilities], runtime: 'chatgpt' };
33
+ task.workerSelection = undefined;
34
+ task.routing = undefined;
35
+ task.timeoutMs = 0;
36
+ task.updatedAt = new Date(now).toISOString();
37
+ return { rescued: true, ageMs };
38
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-relay",
3
- "version": "2.0.55",
3
+ "version": "2.0.57",
4
4
  "description": "Durable async agent jobs coordinated through GitHub pull requests",
5
5
  "type": "module",
6
6
  "bin": {
@@ -36,6 +36,8 @@ A **P0 job** (alias: **self job**) is executed directly by the current active or
36
36
  5. P0/self jobs do not require the normal reviewer gate unless the user explicitly asks for review.
37
37
  6. After implementation and local verification, the orchestrator merges the job itself and completes normal post-merge reconciliation.
38
38
 
39
+ Recovery exception: reconciliation may rescue only a `QUEUED` work Task with `adapter: orchestrator` that has no execution identity and is at least 30 minutes past `createdAt`. It converts that Task in place to a normal ChatGPT-routable Task, preserving its durable intent and Task identity while clearing stale routing state. `RUNNING` and terminal Tasks are not rescued. This is a stale-task safety net, not an asynchronous handoff mechanism for newly created self-work.
40
+
39
41
  `job create` still only creates durable job state; the P0 workflow requires the orchestrator to follow it immediately with task creation and execution.
40
42
 
41
43
  ## Merge completion vs deployment evidence
@@ -0,0 +1,13 @@
1
+ flowchart LR
2
+ %% relay: initial idle
3
+ idle["state: idle"]
4
+ check["state: check"]
5
+ skip["state: skip"]
6
+ rescued["state: rescued"]
7
+ rescuable["guard: rescuable"]
8
+ rescue["action: task.rescueOrchestrator"]
9
+ idle -->|on: orchestrator.rescue.reconcile| check
10
+ check -->|on: orchestrator.rescue.reconcile| rescuable
11
+ rescuable -->|false| skip
12
+ rescuable -->|true| rescue
13
+ rescue -->|to: rescued| rescued