agents-relay 1.0.30 → 1.0.32

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/planner.js CHANGED
@@ -3,6 +3,18 @@ import { readFile } from 'node:fs/promises';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { dirname, join } from 'node:path';
5
5
  import { taskOverallTimeoutMs } from './types.js';
6
+ import { executionContextPrompt } from './execution-context.js';
7
+ export function planningSpec(task) {
8
+ if (!task.output && task.adapter !== 'orchestrator')
9
+ throw new Error(`Initial task ${task.id} requires an output contract`);
10
+ if (!['codex', 'chatgpt', 'orchestrator'].includes(task.adapter))
11
+ throw new Error(`Initial task ${task.id} requires a supported adapter`);
12
+ return { id: task.id, input: task.input, priority: task.priority, storySize: task.storySize, projectName: task.projectName, agentName: task.agentName, dependencies: task.dependencies, capabilities: task.capabilities, adapter: task.adapter, output: task.output ?? { kind: 'task_pr' }, routing: task.routing, continuation: task.continuation, maxAttempts: task.maxAttempts, timeoutMs: task.timeoutMs };
13
+ }
14
+ export function orchestratorPlanningDecision(job, tasks, id, parentTaskId, round, source = 'orchestrator') {
15
+ const now = new Date().toISOString();
16
+ return { jobId: job.id, id, kind: 'planner', priority: job.priority, storySize: 'S', agentName: source === 'orchestrator' ? 'orchestrator' : 'planner', parentTaskId, dependencies: [], capabilities: ['planner'], adapter: 'orchestrator', input: `Planning decision supplied by ${source} for ${tasks.length} task(s)`, state: 'SUCCEEDED', attempt: 1, maxAttempts: 1, leaseOwner: null, leaseExpiresAt: null, executionId: null, threadId: null, result: { summary: source === 'orchestrator' ? 'Initial task set supplied by orchestrator' : 'Planning decision completed' }, plannerResult: { objective_status: 'in_progress', assessment: source === 'orchestrator' ? 'Initial task set supplied by orchestrator' : 'Planning decision completed', next_tasks: tasks.map(planningSpec) }, error: null, timeoutMs: 0, createdAt: now, updatedAt: now, planningDecisionId: id, planningSource: source, planningRound: round };
17
+ }
6
18
  function record(value) {
7
19
  if (!value || typeof value !== 'object' || Array.isArray(value))
8
20
  throw new Error('Planner result must be an object');
@@ -111,7 +123,7 @@ export function parsePlannerOutput(output) {
111
123
  return parsePlannerResult(JSON.parse(candidate));
112
124
  }
113
125
  export function plannerInput(context) {
114
- return JSON.stringify({ objective: context.objective, previous_planner_task_id: context.previousPlannerTaskId, tasks: context.tasks.map(task => ({ id: task.id, kind: task.kind ?? 'work', parent_task_id: task.parentTaskId, state: task.state, input: task.input, result: task.result, planner_result: task.plannerResult, error: task.error })) });
126
+ return JSON.stringify({ objective: context.objective, previous_planner_task_id: context.previousPlannerTaskId, execution_context: context.executionContext, tasks: context.tasks.map(task => ({ id: task.id, kind: task.kind ?? 'work', parent_task_id: task.parentTaskId, state: task.state, input: task.input, result: task.result, planner_result: task.plannerResult, error: task.error })) });
115
127
  }
116
128
  export class CommandObjectivePlanner {
117
129
  command;
@@ -162,9 +174,11 @@ export class AgentObjectivePlanner {
162
174
  this.agentFile = agentFile;
163
175
  }
164
176
  async plan(context, signal) {
165
- const instructions = await readFile(this.agentFile, 'utf8');
177
+ const resolvedPlanner = context.executionContext?.agents.find(agent => agent.name === 'planner')?.path;
178
+ const instructions = await readFile(resolvedPlanner ?? this.agentFile, 'utf8');
166
179
  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);
180
+ const resolvedContext = context.executionContext ? executionContextPrompt(context.executionContext) + '\n\n' : '';
181
+ const prompt = instructions.trim() + '\n\n## Shared Task / Job Contract\n\n' + contract.trim() + '\n\n' + resolvedContext + '## Durable planner input\n\n' + plannerInput(context);
168
182
  return await new Promise((resolve, reject) => {
169
183
  const child = spawn(this.command, ['exec', '--json', '--', prompt], { stdio: ['ignore', 'pipe', 'pipe'] });
170
184
  let stdoutBuffer = '';
@@ -4,9 +4,10 @@ import { 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';
7
+ import { executionContextPrompt, resolveExecutionContext } from './execution-context.js';
7
8
  const PRIORITY_RANK = { P0: 0, P1: 1, P2: 2, P3: 3 };
8
9
  export function compareTaskPriority(a, b, jobPriority = 'P2') { const ar = PRIORITY_RANK[a.priority ?? jobPriority]; const br = PRIORITY_RANK[b.priority ?? jobPriority]; return ar - br || a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id); }
9
- export function managedWorkerInput(job, task) {
10
+ export function managedWorkerInput(job, task, context) {
10
11
  if (!['codex', 'chatgpt'].includes(task.adapter) || !job.repository || job.prNumber <= 0)
11
12
  return task.input;
12
13
  if (!task.output)
@@ -20,9 +21,9 @@ PR: ${prUrl}
20
21
  Job: ${job.id}
21
22
  Task: ${task.id}
22
23
  Parent task: ${task.parentTaskId ?? 'none'}
23
- Project: ${task.projectName ?? 'unspecified'}
24
+ Project: ${task.projectName ?? context?.project.name ?? 'unspecified'}
24
25
 
25
- ${task.input}
26
+ ${context ? executionContextPrompt(context) + '\n\n' : ''}${task.input}${task.parentTaskId ? '\n\n[Nested delegation contract]\nAny sub-work must be created as a durable Agents Relay child task with parentTaskId=' + task.id + '; do not directly spawn arbitrary child agents.' : ''}
26
27
 
27
28
  [Output contract]
28
29
  ${output}
@@ -125,12 +126,17 @@ export class Reconciler {
125
126
  }
126
127
  }
127
128
  await this.recoverLostExecutions(job, now);
129
+ await this.recoverPlannerChildren(job);
128
130
  for (const task of job.tasks)
129
131
  if (task.state === 'CANCELLED') {
130
132
  this.live.get(task.id)?.execution.cancel();
131
133
  this.plannerLive.get(task.id)?.controller.abort();
132
134
  }
133
135
  let scheduled = schedule(await this.store.load(jobId));
136
+ if (prState === 'OPEN') {
137
+ await this.ensureFinalReview(scheduled, prState, headSha);
138
+ scheduled = schedule(await this.store.load(jobId));
139
+ }
134
140
  if (prState === 'OPEN' && scheduled.executionMode === 'autonomous') {
135
141
  scheduled = schedule(await this.maybeCreatePlanner(scheduled));
136
142
  }
@@ -284,16 +290,24 @@ export class Reconciler {
284
290
  async maybeCreatePlanner(job) {
285
291
  if (job.executionMode !== 'autonomous')
286
292
  return job;
293
+ if (job.initialPlanning?.status === 'pending')
294
+ return job;
287
295
  const planners = job.tasks.filter(task => task.kind === 'planner').sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
288
296
  if (planners.some(task => ['QUEUED', 'READY', 'RUNNING', 'WAITING'].includes(task.state)))
289
297
  return job;
290
298
  if (job.tasks.some(task => task.state === 'FAILED' || task.state === 'BLOCKED'))
291
299
  return job;
300
+ if (job.tasks.length > 0 && !planners.some(task => task.state === 'SUCCEEDED'))
301
+ return job;
292
302
  if (job.tasks.some(task => task.kind !== 'planner' && !['SUCCEEDED', 'CANCELLED'].includes(task.state)))
293
303
  return job;
304
+ if (planners.length > 0 && planners.at(-1)?.plannerResult?.objective_status === 'in_progress' && !job.tasks.some(task => task.planningDecisionId === planners.at(-1)?.id))
305
+ return job;
294
306
  const latest = planners.at(-1);
295
307
  if (latest?.plannerResult?.objective_status === 'satisfied')
296
308
  return job;
309
+ if (latest && job.review && job.review.outcome !== 'approved')
310
+ return job;
297
311
  const used = new Set(job.tasks.map(task => task.id));
298
312
  let number = planners.length + 1;
299
313
  let id = `planner-${number}`;
@@ -302,11 +316,34 @@ export class Reconciler {
302
316
  id = `planner-${number}`;
303
317
  }
304
318
  const now = new Date().toISOString();
305
- const task = { jobId: job.id, id, kind: 'planner', storySize: 'M', agentName: 'planner', parentTaskId: latest?.id ?? null, dependencies: [], capabilities: ['planner'], adapter: 'orchestrator', input: plannerInput({ job, tasks: job.tasks, objective: job.objective ?? job.description ?? job.title, previousPlannerTaskId: latest?.id ?? null }), continuation: null, continuationDeliveredAt: null, state: 'QUEUED', attempt: 0, maxAttempts: 3, leaseOwner: null, leaseExpiresAt: null, executionId: null, threadId: null, result: null, plannerResult: null, error: null, timeoutMs: this.options.plannerTimeoutMs ?? storySizeTimeoutMs('M'), createdAt: now, updatedAt: now };
319
+ const task = { jobId: job.id, id, kind: 'planner', storySize: 'M', agentName: 'planner', parentTaskId: latest?.id ?? null, dependencies: [], capabilities: ['planner'], adapter: 'orchestrator', input: plannerInput({ job, tasks: job.tasks, objective: job.objective ?? job.description ?? job.title, previousPlannerTaskId: latest?.id ?? null }), continuation: null, continuationDeliveredAt: null, state: 'QUEUED', attempt: 0, maxAttempts: 3, leaseOwner: null, leaseExpiresAt: null, executionId: null, threadId: null, result: null, plannerResult: null, error: null, timeoutMs: this.options.plannerTimeoutMs ?? storySizeTimeoutMs('M'), createdAt: now, updatedAt: now, planningDecisionId: id, planningSource: 'model-planner', planningRound: planners.length + 1 };
306
320
  await this.store.appendTask(task);
307
321
  await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'planner.created', 'queued', `Planner task ${task.id} created`, 'orchestrator'));
308
322
  return this.store.load(job.id);
309
323
  }
324
+ async recoverPlannerChildren(job) {
325
+ for (const planner of job.tasks.filter(task => task.kind === 'planner' && task.state === 'SUCCEEDED' && task.plannerResult)) {
326
+ const children = planner.plannerResult?.next_tasks.map(spec => this.plannerChild(job, planner, spec)) ?? [];
327
+ for (const child of children) {
328
+ const existing = job.tasks.find(item => item.id === child.id);
329
+ if (existing) {
330
+ if (!samePlannerChildDefinition(existing, child))
331
+ throw new Error(`Planner task ${child.id} already exists with a different definition`);
332
+ continue;
333
+ }
334
+ await this.store.appendTask(child);
335
+ job.tasks.push(child);
336
+ }
337
+ }
338
+ }
339
+ async resolvedContext(job, task, role) {
340
+ const projectPath = this.options.projectPathForJob ? await this.options.projectPathForJob(job) : task.routing?.cwd ?? process.cwd();
341
+ return resolveExecutionContext(projectPath, role, {
342
+ ...(this.options.executionContextRoots ?? {}),
343
+ registeredAgents: await this.store.listAgents(),
344
+ runtimes: this.options.adapters.map(adapter => ({ name: adapter.name, id: adapter.id, capabilities: [...adapter.capabilities] })),
345
+ });
346
+ }
310
347
  async launch(job, task) {
311
348
  const adapter = this.options.adapters.find(x => x.name === task.adapter);
312
349
  if (!adapter) {
@@ -322,8 +359,11 @@ export class Reconciler {
322
359
  return;
323
360
  }
324
361
  let workerInput;
362
+ let executionContext;
325
363
  try {
326
- workerInput = managedWorkerInput(job, task);
364
+ const role = task.agentName === 'reviewer' ? 'reviewer' : task.parentTaskId ? 'nested-worker' : 'worker';
365
+ executionContext = await this.resolvedContext(job, task, role);
366
+ workerInput = managedWorkerInput(job, task, executionContext);
327
367
  }
328
368
  catch (error) {
329
369
  transitionTask(task, 'BLOCKED');
@@ -342,7 +382,7 @@ export class Reconciler {
342
382
  await this.store.saveTask(task);
343
383
  const controller = new AbortController();
344
384
  let execution;
345
- const workerTask = { ...task, input: workerInput };
385
+ const workerTask = { ...task, input: workerInput, routing: task.routing ? { ...task.routing, cwd: task.routing.cwd ?? executionContext.project.path } : task.routing };
346
386
  const startAckSetup = task.adapter === 'chatgpt' ? await this.startAck(job.id, task.id, Date.now(), this.options.startAckTimeoutMs ?? chatGptStartAckTimeoutMs(task.timeoutMs)) : null;
347
387
  const closePolicy = task.chatgptTabClosePolicy ?? this.options.chatgptTabClosePolicy ?? adapter.tabClosePolicy ?? 'after-start';
348
388
  const terminalWait = task.adapter === 'chatgpt' && closePolicy === 'after-terminal'
@@ -351,6 +391,7 @@ export class Reconciler {
351
391
  const launchContext = {
352
392
  ...(startAckSetup ? { waitForWorkerStart: startAckSetup.ack } : {}),
353
393
  ...(terminalWait ? { waitForTerminal: terminalWait.promise } : {}),
394
+ executionContext,
354
395
  };
355
396
  try {
356
397
  execution = adapter.launch(workerTask, controller.signal, launchContext);
@@ -435,7 +476,8 @@ export class Reconciler {
435
476
  this.plannerLive.set(task.id, { executionId: executionId, controller, timer });
436
477
  let promise;
437
478
  try {
438
- promise = planner.plan({ job, tasks: job.tasks, objective: job.objective ?? job.description ?? job.title, previousPlannerTaskId: task.parentTaskId }, controller.signal);
479
+ const executionContext = await this.resolvedContext(job, task, 'planner');
480
+ promise = planner.plan({ job, tasks: job.tasks, objective: job.objective ?? job.description ?? job.title, previousPlannerTaskId: task.parentTaskId, executionContext }, controller.signal);
439
481
  }
440
482
  catch (error) {
441
483
  await this.finishPlanner(job.id, task.id, executionId, error instanceof Error ? error : new Error(String(error)));
@@ -467,14 +509,15 @@ export class Reconciler {
467
509
  }
468
510
  try {
469
511
  const result = parsePlannerResult(raw);
470
- for (const spec of result.next_tasks) {
471
- const child = this.plannerChild(job, task, spec);
512
+ const children = result.next_tasks.map(spec => this.plannerChild(job, task, spec));
513
+ for (const child of children) {
472
514
  const existing = job.tasks.find(item => item.id === child.id);
473
515
  if (!existing)
474
- await this.store.appendTask(child);
516
+ continue;
475
517
  else if (!samePlannerChildDefinition(existing, child))
476
518
  throw new Error(`Planner task ${child.id} already exists with a different definition`);
477
519
  }
520
+ // Persist the completed decision before exposing executable children.
478
521
  task.plannerResult = result;
479
522
  task.result = { summary: result.assessment, data: { objective_status: result.objective_status, next_tasks: result.next_tasks } };
480
523
  task.error = null;
@@ -482,22 +525,32 @@ export class Reconciler {
482
525
  task.leaseExpiresAt = null;
483
526
  transitionTask(task, 'SUCCEEDED');
484
527
  await this.store.saveTask(task);
528
+ for (const child of children)
529
+ if (!job.tasks.some(item => item.id === child.id))
530
+ await this.store.appendTask(child);
485
531
  await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'planner.completed', 'succeeded', `Planner ${task.id} completed: ${result.objective_status}`, 'orchestrator', { objective_status: result.objective_status }));
486
532
  }
487
533
  catch (caught) {
488
534
  const message = caught instanceof Error ? caught.message : String(caught);
489
- task.error = message;
490
- task.leaseOwner = null;
491
- task.leaseExpiresAt = null;
492
- transitionTask(task, task.attempt < task.maxAttempts ? 'READY' : 'FAILED');
493
- await this.store.saveTask(task);
494
- await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'planner.failed', 'failed', `Planner ${task.id}: ${message}`, 'user'));
535
+ if (task.state === 'SUCCEEDED') {
536
+ task.error = `Child materialization failed: ${message}`;
537
+ await this.store.saveTask(task);
538
+ await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'planner.children_failed', 'failed', `Planner ${task.id} child materialization failed: ${message}`, 'user'));
539
+ }
540
+ else {
541
+ task.error = message;
542
+ task.leaseOwner = null;
543
+ task.leaseExpiresAt = null;
544
+ transitionTask(task, task.attempt < task.maxAttempts ? 'READY' : 'FAILED');
545
+ await this.store.saveTask(task);
546
+ await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'planner.failed', 'failed', `Planner ${task.id}: ${message}`, 'user'));
547
+ }
495
548
  }
496
549
  this.detach(this.reconcile(jobId), jobId, task.id, 'planner follow-up reconciliation');
497
550
  }
498
551
  plannerChild(job, planner, spec) {
499
552
  const now = new Date().toISOString();
500
- 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 ?? [], adapter: spec.adapter, input: spec.input, output: spec.output, routing: spec.routing, 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', spec.routing?.reasoning), createdAt: now, updatedAt: now };
553
+ 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 ?? [], adapter: spec.adapter, input: spec.input, output: spec.output, routing: spec.routing, 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', spec.routing?.reasoning), createdAt: now, updatedAt: now, planningDecisionId: planner.id, planningSource: planner.planningSource ?? 'model-planner', planningRound: planner.planningRound ?? 1 };
501
554
  }
502
555
  clearExecution(taskId, executionId) {
503
556
  const live = this.live.get(taskId);
package/dist/relayd.js CHANGED
@@ -13,7 +13,7 @@ import { dashboardManagedJobs, GitHubStore, InMemoryStore, loadManagedGitHubPull
13
13
  import { githubAuthContext } from './github-auth.js';
14
14
  import { isEntrypoint, runtimePlanner, runtimeReviewer, startService, SERVICE_DEFAULTS } from './cli.js';
15
15
  import { codexAndZaiUsageRegistry } from './usage.js';
16
- import { defaultWorkspaceRoot, discoverWorkspaceRepositories } from './workspace.js';
16
+ import { defaultWorkspaceRoot, discoverWorkspaceRepositories, resolveManagedProjectPath } from './workspace.js';
17
17
  export const daemonHelp = `Agents Relay daemon runs the dashboard and worker pool.
18
18
 
19
19
  Usage:
@@ -64,10 +64,12 @@ export async function runDaemon(argv) {
64
64
  const trusted = auth.trustedAuthors;
65
65
  const concurrency = Number(value(argv, '--concurrency', '4'));
66
66
  const bus = value(argv, '--events') === 'nats' ? new NatsEventBus(value(argv, '--nats-url', 'nats://127.0.0.1:4222'), value(argv, '--subject-prefix', 'neo.events.job')) : undefined;
67
- const makeReconciler = (store, maxConcurrent) => new Reconciler(store, { owner: `relayd-${process.pid}`, maxConcurrent, leaseMs: Number(value(argv, '--lease-ms', '300000')), adapters: [new CodexAdapter(value(argv, '--codex', 'codex')), new ChatGptAdapter()], continuations: [new CodexThreadContinuation(value(argv, '--codex', 'codex')), new CommandContinuation(), new WebhookContinuation()], planner: runtimePlanner(argv), eventBus: bus, reviewer: runtimeReviewer() });
68
- const repositories = normalized.repository
69
- ? [normalized.repository]
70
- : (await discoverWorkspaceRepositories(normalized.workspaceRoot ?? defaultWorkspaceRoot())).map(item => item.repository);
67
+ const workspaceRoot = normalized.workspaceRoot ?? defaultWorkspaceRoot();
68
+ const discoveredRepositories = normalized.repository ? [] : await discoverWorkspaceRepositories(workspaceRoot);
69
+ const repositories = normalized.repository ? [normalized.repository] : discoveredRepositories.map(item => item.repository);
70
+ const repositoryPaths = new Map(discoveredRepositories.map(item => [item.repository, item.path]));
71
+ const explicitProjectPath = value(argv, '--project-path');
72
+ const makeReconciler = (store, maxConcurrent) => new Reconciler(store, { owner: `relayd-${process.pid}`, maxConcurrent, leaseMs: Number(value(argv, '--lease-ms', '300000')), adapters: [new CodexAdapter(value(argv, '--codex', 'codex')), new ChatGptAdapter()], continuations: [new CodexThreadContinuation(value(argv, '--codex', 'codex')), new CommandContinuation(), new WebhookContinuation()], planner: runtimePlanner(argv), eventBus: bus, reviewer: runtimeReviewer(), projectPathForJob: job => repositoryPaths.get(job.repository) ?? resolveManagedProjectPath(job.repository, explicitProjectPath, workspaceRoot) });
71
73
  if (repositories.length === 0)
72
74
  throw new Error(`No Git repositories with GitHub origins found under ${normalized.workspaceRoot ?? defaultWorkspaceRoot()}`);
73
75
  const pool = new RepositoryWorkerPool(client, repositories, trusted, concurrency, makeReconciler);
package/dist/store.js CHANGED
@@ -82,7 +82,7 @@ function immutableTaskDefinition(task) {
82
82
  } : undefined;
83
83
  return {
84
84
  jobId: task.jobId, id: task.id, priority: task.priority, storySize: task.storySize ?? 'M', projectName: task.projectName, agentName: task.agentName,
85
- kind: task.kind ?? 'work', parentTaskId: task.parentTaskId, dependencies: task.dependencies, capabilities: task.capabilities, adapter: task.adapter,
85
+ kind: task.kind ?? 'work', planningDecisionId: task.planningDecisionId, planningSource: task.planningSource, planningRound: task.planningRound, parentTaskId: task.parentTaskId, dependencies: task.dependencies, capabilities: task.capabilities, adapter: task.adapter,
86
86
  input: task.input, output: task.output, routing, chatgptTabClosePolicy: task.chatgptTabClosePolicy ?? 'after-start', continuation: task.continuation ?? null, maxAttempts: task.maxAttempts, timeoutMs: task.timeoutMs
87
87
  };
88
88
  }
package/dist/workspace.js CHANGED
@@ -64,6 +64,17 @@ export async function discoverWorkspaceRepositories(root) {
64
64
  }
65
65
  return [...unique.values()].sort((a, b) => a.repository.localeCompare(b.repository));
66
66
  }
67
+ export async function resolveWorkspaceRepositoryPath(repository, root = defaultWorkspaceRoot()) {
68
+ const match = (await discoverWorkspaceRepositories(root)).find(item => item.repository === repository);
69
+ if (!match)
70
+ throw new Error(`Repository ${repository} not found under workspace ${resolve(root)}; pass --project-path explicitly`);
71
+ return match.path;
72
+ }
73
+ export async function resolveManagedProjectPath(repository, explicitProjectPath = '', root = defaultWorkspaceRoot(), cwd = process.cwd()) {
74
+ if (explicitProjectPath)
75
+ return resolve(explicitProjectPath);
76
+ return repository ? resolveWorkspaceRepositoryPath(repository, root) : resolve(cwd);
77
+ }
67
78
  function repositoryPathRank(path, root) {
68
79
  const relativePath = relative(root, path);
69
80
  if (!relativePath.includes('/'))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-relay",
3
- "version": "1.0.30",
3
+ "version": "1.0.32",
4
4
  "description": "Durable async agent jobs coordinated through GitHub pull requests",
5
5
  "type": "module",
6
6
  "bin": {
@@ -82,14 +82,30 @@ For Codex and ChatGPT workers, Agents Relay adds only durable execution identity
82
82
 
83
83
  The job/task descriptions remain the durable source of intent. Adapters do not invent task meaning.
84
84
 
85
+ ## Shared execution context
86
+
87
+ Before planner, reviewer, normal worker, or nested-worker execution, resolve one execution context from the bound project path. The shared resolver owns project instructions, skills, visible agents, and runtime capabilities with project-local > Neo shared > global `~/.agents` precedence. Concrete paths are execution-time data and must not be persisted into durable task records.
88
+
89
+ Leaf workers consume the resolved instruction/skill/agent paths, not the full top-level XChat orchestrator contract. Nested workers may inspect visible agents, but any delegation must be represented as an Agents Relay durable child task preserving `parentTaskId`, output contract, routing, leases/retries, events, and review gates.
90
+
85
91
  ## ChatGPT worker
86
92
 
87
- The ChatGPT adapter uses the packaged `chatgpt-browser-worker` one-shot path. It opens a fresh Temporary Chat tab, submits the complete task with the account defaults, verifies acceptance, applies the configured `after-start` (default), `never`, or `after-terminal` owned-tab close policy, and returns a submission receipt.
93
+ The ChatGPT adapter uses the `chatgpt-browser-worker` path from the shared execution context; Relay does not bundle or independently rediscover that skill. It opens a fresh Temporary Chat tab, submits the complete task with the account defaults, verifies acceptance, applies the configured `after-start` (default), `never`, or `after-terminal` owned-tab close policy, and returns a submission receipt.
88
94
 
89
95
  ChatGPT thread identity is diagnostic only. It is not persisted as a resumable task handle, and Relay does not poll, resume, retrieve a result from, or delete a ChatGPT conversation as part of task lifecycle.
90
96
 
91
97
  ## Autonomous planner
92
98
 
99
+ No executable task may be materialized without a completed durable planning
100
+ decision. Job creation with caller-supplied initial work records an
101
+ orchestrator-owned `planner-0` decision before its initial tasks; autonomous
102
+ mode must not start a model planner in that case. Objective-only autonomous
103
+ jobs may start a real model planner, and planner children are created only
104
+ after that planner succeeds. Fixed jobs do not automatically create another
105
+ planning round. Each task exposes `planningDecisionId`, `planningSource`, and
106
+ `planningRound` for dashboard lineage. Nested worker delegation must create its
107
+ durable planning decision and children through Relay as well.
108
+
93
109
  Planner-created child tasks must also be descriptive agent work. They must select `codex` or `chatgpt` and include an explicit output contract. A planner must never generate shell-command worker tasks.
94
110
 
95
111
  ## Durable truth
@@ -1,67 +0,0 @@
1
- ---
2
- name: chatgpt-browser-worker
3
- description: Submit an isolated one-shot ChatGPT browser worker task through browser-harness with a required durable output destination.
4
- ---
5
-
6
- # ChatGPT browser worker
7
-
8
- Use this skill to hand one self-contained task to ChatGPT through the authenticated
9
- browser session. The caller does not manage ChatGPT threads, model selection,
10
- thinking level, polling, or result retrieval.
11
-
12
- ## Output contract
13
-
14
- Every task handed to this worker MUST declare exactly one durable output mode
15
- before the worker is launched.
16
-
17
- ### 1. Task / PR output
18
-
19
- Use this for repository or managed-task work.
20
-
21
- The task prompt must identify the exact managed task/PR that owns the result.
22
- The worker performs the requested work directly against that task/PR and records
23
- its final outcome there.
24
-
25
- Output:
26
- task/PR
27
- <exact managed task/PR identity and required final action>
28
-
29
- ### 2. File output
30
-
31
- Use this for research, analysis, reports, or other artifact-producing work.
32
-
33
- The task prompt must name the exact file path. That file is the authoritative
34
- result.
35
-
36
- Output:
37
- file
38
- <exact file path>
39
-
40
- A Browser ChatGPT worker task without one of these output declarations is
41
- invalid and must not be launched. The orchestrator chooses and writes the output
42
- contract when it creates the worker task; the worker must not invent or change
43
- the destination.
44
-
45
- Progress and terminal success/failure are always reported through the normal
46
- Neo event contract. Events are execution observability, not a third output mode.
47
-
48
- ## Runtime behavior
49
-
50
- The worker is one-shot. It opens an isolated worker-owned ChatGPT tab, submits
51
- the complete task, verifies that ChatGPT accepted the submission, and applies
52
- the configured tab-close policy. `after-start` waits for the exact
53
- post-submission worker `task.started` acknowledgement, `never` leaves the owned
54
- tab open for debugging, and `after-terminal` waits for a new exact-task
55
- `task.completed`, `task.failed`, `task.blocked`, or `task.cancelled` event.
56
- ChatGPT continues the task independently and publishes the result through the
57
- declared output contract. If the start acknowledgement does not arrive within
58
- 60 seconds, close only the owned tab and fail with `worker_start_timeout`.
59
-
60
- The worker must never interact through a pre-existing user ChatGPT tab.
61
- Browser details, transient conversation identity, submission verification,
62
- model/default-thinking behavior, and tab cleanup are implementation concerns of
63
- the worker and are not part of the caller-facing contract.
64
-
65
- The runtime entry point is the browser-worker agent. Callers launch that agent
66
- with the complete task prompt and declared output; they do not call helper
67
- scripts directly.
@@ -1,47 +0,0 @@
1
- ---
2
- name: browser-worker
3
- description: Submit one isolated ChatGPT browser task and return after the task is accepted.
4
- type: worker
5
- ---
6
-
7
- # Browser Worker Agent
8
-
9
- You are the runtime owner for the `chatgpt-browser-worker` skill. Load and follow
10
- that skill and the `browser-harness` skill before execution.
11
-
12
- The outer orchestrator gives you one complete task. That task MUST already
13
- contain exactly one output declaration defined by the skill:
14
-
15
- - `task/PR` with the exact managed task/PR identity and required final action; or
16
- - `file` with the exact authoritative output path.
17
-
18
- If the output declaration is missing or ambiguous, do not invent one.
19
-
20
- ## Runtime rules
21
-
22
- - Use `browser-harness` for every ChatGPT browser interaction.
23
- - Execute only a one-shot submission. Do not expose or operate a
24
- create/resume/status/result/continue lifecycle for callers.
25
- - Always create a fresh worker-owned tab. Never type, upload, click New chat,
26
- select a Project, or submit through a pre-existing user ChatGPT tab.
27
- - Use Temporary Chat and the account's existing default model/thinking
28
- settings. Do not change model or thinking settings.
29
- - Upload requested files, submit the complete task prompt, and verify that the
30
- submission became a new user turn.
31
- - Once submission is verified, follow the configured owned-tab close policy:
32
- `after-start` closes after a new worker-owned `task.started` event, `never`
33
- leaves the owned tab open for test/debug use, and `after-terminal` closes only
34
- after a new exact-task `task.completed`, `task.failed`, `task.blocked`, or
35
- `task.cancelled` event. Never close a user tab. Do not wait for the assistant
36
- response or reopen/poll the conversation. If the start acknowledgement does
37
- not arrive within 60 seconds, close only the owned tab and report a timeout.
38
- - Treat any observed conversation/thread identity only as diagnostic evidence,
39
- never as a resumable handle.
40
- - Progress and terminal success/failure for the actual delegated task are
41
- reported through the normal Neo event contract by the executing task. The
42
- final durable result goes to the declared task/PR or file output.
43
- - Stop on authentication, MFA, consent, or ambiguous browser state rather than
44
- bypassing it.
45
-
46
- Helper scripts under `scripts/` are implementation details. Callers must not
47
- invoke them directly.
@@ -1,30 +0,0 @@
1
- """Bounded polling helpers for ChatGPT submit readiness."""
2
- from __future__ import annotations
3
-
4
- import time
5
- from collections.abc import Callable, Mapping
6
-
7
-
8
- def wait_until_stable(
9
- read_state: Callable[[], Mapping[str, object]],
10
- ready: Callable[[Mapping[str, object]], bool],
11
- *,
12
- timeout: float,
13
- interval: float = 0.25,
14
- stable_samples: int = 2,
15
- phase: str,
16
- ) -> Mapping[str, object]:
17
- """Return only after the observable state is ready for stable_samples polls."""
18
- deadline = time.monotonic() + timeout
19
- stable = 0
20
- last_state: Mapping[str, object] = {}
21
- while time.monotonic() < deadline:
22
- last_state = read_state()
23
- if ready(last_state):
24
- stable += 1
25
- if stable >= stable_samples:
26
- return last_state
27
- else:
28
- stable = 0
29
- time.sleep(interval)
30
- raise RuntimeError(f"Temporary Chat {phase} was not observed ready")