agents-relay 2.0.54 → 2.0.56

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.
@@ -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');
@@ -14,7 +14,7 @@ import { executionEventSource, withExecutionBoundary } from './reconciler-runtim
14
14
  export { executionEventSource } from './reconciler-runtime.js';
15
15
  import { CallbackWorkflowEffectHandler } from './workflow/effects.js';
16
16
  import { pendingTroubleshooterTasks } from './troubleshooter.js';
17
- import { prepareManagedExecutionWorktree } from './workspace.js';
17
+ import { prepareManagedExecutionWorktree, taskUsesCanonicalProjectRun } from './workspace.js';
18
18
  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';
@@ -412,7 +412,8 @@ export class Reconciler extends ReconcilerReviewBase {
412
412
  try {
413
413
  const role = task.agentName === 'reviewer' ? 'reviewer' : task.agentName === 'troubleshooter' ? 'troubleshooter' : task.parentTaskId ? 'nested-worker' : 'worker';
414
414
  let projectPath = this.options.projectPathForJob ? await this.options.projectPathForJob(job) : task.routing?.cwd ?? process.cwd();
415
- if (['codex', 'chatgpt'].includes(task.adapter) && this.store.pullRequestHead && task.executionId) {
415
+ const canonicalProjectRun = taskUsesCanonicalProjectRun(task, job.tasks);
416
+ if (['codex', 'chatgpt'].includes(task.adapter) && this.store.pullRequestHead && task.executionId && !canonicalProjectRun) {
416
417
  managedHead = await this.store.pullRequestHead();
417
418
  if (!managedHead)
418
419
  throw new Error(`PR #${job.prNumber} has no resolvable head ref`);
@@ -435,9 +436,12 @@ export class Reconciler extends ReconcilerReviewBase {
435
436
  let execution;
436
437
  const sourceId = executionEventSource(job.id, task.id, durableExecutionId);
437
438
  const baseWorkerInput = await managedWorkerInput(job, task, executionContext);
439
+ const canonicalProjectRun = taskUsesCanonicalProjectRun(task, job.tasks);
438
440
  const workerInput = managedHead
439
441
  ? `${baseWorkerInput}\n\n[Managed PR checkout]\nExecution cwd: ${executionContext.project.path}\nExpected PR head: ${managedHead.ref} @ ${managedHead.sha}\nThis checkout is isolated from the user's primary checkout. Commit only here. Push durable changes with: git push origin HEAD:${managedHead.ref}\nDo not switch, reset, merge into, or commit on the primary checkout.`
440
- : baseWorkerInput;
442
+ : canonicalProjectRun
443
+ ? `${baseWorkerInput}\n\n[Canonical Neo project run]\nExecution cwd: ${executionContext.project.path}\nThis Task owns or descends from a durable runs/ artifact. Work directly in this canonical project directory so all continuations, reviews, QA, and publication steps share the same runs/ evidence. Do not create or switch to an Agents Relay task worktree for this runtime work. Keep generated/private execution artifacts under runs/ and do not commit them.`
444
+ : baseWorkerInput;
441
445
  const workerTask = { ...task, input: workerInput, routing: task.routing ? { ...task.routing, cwd: executionContext.project.path } : task.routing };
442
446
  const startAckSetup = task.adapter === 'chatgpt' ? await this.startAck(job.id, task.id, sourceId, Date.now(), this.options.startAckTimeoutMs ?? chatGptStartAckTimeoutMs(task.timeoutMs)) : null;
443
447
  const closePolicy = task.chatgptTabClosePolicy ?? this.options.chatgptTabClosePolicy ?? adapter.tabClosePolicy ?? 'after-start';
package/dist/workspace.js CHANGED
@@ -92,6 +92,24 @@ export function resolveRepositoryProjectPath(repositoryRoot, projectPath) {
92
92
  throw new Error('Job projectPath escapes repository root');
93
93
  return target;
94
94
  }
95
+ export function isProjectRunPath(path) {
96
+ if (!path)
97
+ return false;
98
+ const normalized = path.replace(/\\/g, '/').replace(/^\.\//, '');
99
+ return normalized === 'runs' || normalized.startsWith('runs/');
100
+ }
101
+ export function taskUsesCanonicalProjectRun(task, tasks) {
102
+ const byId = new Map(tasks.map(item => [item.id, item]));
103
+ const seen = new Set();
104
+ let current = task;
105
+ while (current && !seen.has(current.id)) {
106
+ seen.add(current.id);
107
+ if (current.output?.kind === 'file' && isProjectRunPath(current.output.path))
108
+ return true;
109
+ current = current.parentTaskId ? byId.get(current.parentTaskId) : undefined;
110
+ }
111
+ return false;
112
+ }
95
113
  async function git(path, args) {
96
114
  return (await execFileAsync('git', ['-C', path, ...args])).stdout.trim();
97
115
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-relay",
3
- "version": "2.0.54",
3
+ "version": "2.0.56",
4
4
  "description": "Durable async agent jobs coordinated through GitHub pull requests",
5
5
  "type": "module",
6
6
  "bin": {
@@ -125,6 +125,20 @@ The scheduler heartbeat filters cached managed Jobs, reloads each due Job to val
125
125
 
126
126
  ## Task evidence and head identity
127
127
 
128
+ ### Canonical Neo Project runs boundary
129
+
130
+ `runs/` is durable runtime evidence, not source code and not temporary checkout state. When a Task's declared file output is under `runs/`, that Task and every semantic descendant execute directly in the canonical resolved project directory. Agents Relay MUST NOT create or switch those Tasks into per-execution Git worktrees.
131
+
132
+ This is a strong execution boundary:
133
+
134
+ - tracked source/code work may use a managed PR worktree;
135
+ - Neo Project runtime work under `runs/` uses the canonical project directory;
136
+ - continuations, reviews, QA, render, and publication Tasks beneath a `runs/` owner reuse that same canonical runtime directory;
137
+ - `runs/` remains ignored/private and must not be committed merely to move evidence between Tasks;
138
+ - a new Task/worktree must never make prior run evidence disappear.
139
+
140
+ For example, a `neo-build-log` episode whose output is `runs/2026-10-02/package.md` executes in the canonical `neo/neo-build-log` project directory, and its review/render/publish descendants see the same `runs/2026-10-02/` tree.
141
+
128
142
  Task inputs must make evidence freshness and target identity explicit when those facts affect correctness. A Task may bind its own review, audit, or source evidence to an immutable SHA/version. That frozen target belongs to that Task; it must not be propagated mechanically into later dependent work. A downstream release, deploy, publication, or other Task that runs after dependencies complete must resolve the current durable Job/PR head at execution time unless the Job objective explicitly requires the earlier immutable target.
129
143
 
130
144
  When success depends on the actual current UI, browser state, store listing, runtime state, or another externally observable surface, the Task contract must require fresh evidence from that real current surface. Reusing an existing screenshot, prepared asset, cached observation, or older evidence is valid only when the Job explicitly permits it.