agents-relay 1.0.31 → 1.0.33
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 +11 -4
- package/dist/adapters.js +6 -1
- package/dist/cli.js +43 -21
- package/dist/dashboard.js +1 -1
- package/dist/planner.js +11 -0
- package/dist/reconciler.js +58 -16
- package/dist/store.js +10 -3
- package/package.json +1 -1
- package/skills/agents-relay/SKILL.md +10 -0
package/README.md
CHANGED
|
@@ -31,9 +31,16 @@ npx agents-relay task update --repo OWNER/REPO --pr 12 --id job-1 --task-id chil
|
|
|
31
31
|
--capabilities model,chatgpt,browser-harness
|
|
32
32
|
~~~
|
|
33
33
|
|
|
34
|
-
Jobs default to `fixed`, preserving the original submit-and-run behavior.
|
|
35
|
-
|
|
36
|
-
|
|
34
|
+
Jobs default to `fixed`, preserving the original submit-and-run behavior. Every
|
|
35
|
+
executable task is now created by a completed durable planning decision. `job
|
|
36
|
+
create --start` records `planner-0` (supplied by the orchestrator) and the
|
|
37
|
+
initial task atomically from the caller's perspective; retrying the command is
|
|
38
|
+
idempotent. Use `--mode autonomous` with `init` or `job create` to make
|
|
39
|
+
reconciliation own planner progress. Autonomous creation with only an objective
|
|
40
|
+
may create a model planner first, but it cannot materialize work until that
|
|
41
|
+
planner succeeds. Fixed jobs never grow another planning round.
|
|
42
|
+
|
|
43
|
+
The durable status JSON and dashboard show the mode and planning provenance. An
|
|
37
44
|
autonomous runtime uses the bundled `skills/agents-relay/agents/planner.agent.md` by default; `--planner-command` is an optional override
|
|
38
45
|
snapshot as JSON on stdin and must return `{ "objective_status": "in_progress"|"satisfied", "assessment": "...", "next_tasks": [...] }`.
|
|
39
46
|
Planner-created tasks use stable IDs and the existing parent/subtask tree, so
|
|
@@ -107,7 +114,7 @@ The minimal agent-network surface is machine-driven registration and discovery.
|
|
|
107
114
|
|
|
108
115
|
Managed tasks are descriptive work for agents, not shell commands. Use `codex` or `chatgpt` for worker tasks. Commands such as tests, build tools, Markad Vision, ffmpeg, and other CLIs are invoked by the orchestrator or by an executing agent; they are not worker adapters.
|
|
109
116
|
|
|
110
|
-
Every model-backed task declares a durable output with `--output task-pr` or `--output file --output-path PATH`. Agents Relay
|
|
117
|
+
Every model-backed task declares a durable output with `--output task-pr` or `--output file --output-path PATH`. Agents Relay bootstraps workers with the managed PR URL, durable task-comment URL, job/task identity, project, output contract, and mandatory terminal-event contract; workers retrieve the task intent from GitHub rather than receiving a duplicate copy of the stored task prompt.
|
|
111
118
|
|
|
112
119
|
For Codex and ChatGPT, **events are authoritative task state**. Event publication uses the canonical Neo `events-bus` surface (`events__publish` for sandboxed/hosted workers, or `NEO_EVENTS_EMIT` for direct local workers); Agents Relay only subscribes and reconciles. Relay publishes `task.process.launched` only after successful adapter launch; `task.started` is worker-owned and must be a new, correlated start acknowledgement. Codex publishes `task.process.exited` when its synchronous runtime exits, while ChatGPT publishes `task.process.async_exited` after its submission runtime exits; neither substitutes for `task.completed`, `task.failed`, `task.blocked`, or `task.cancelled`. The ChatGPT worker-owned tab follows the configured close policy: `after-start` waits for the correlated start acknowledgement, `never` keeps it open for debug, and `after-terminal` waits for a new exact-task terminal event; start-ack failure closes only the owned tab after 60 seconds. The executing agent must publish exactly one correlated terminal event. Relay persists the matching durable state only from that terminal event. Model-backed tasks require an event bus and time out explicitly if no terminal event arrives.
|
|
113
120
|
|
package/dist/adapters.js
CHANGED
|
@@ -108,7 +108,12 @@ export class StartAckTimeout extends LaunchFailure {
|
|
|
108
108
|
}
|
|
109
109
|
async function loadBrowserWorkerRuntime(context, explicit) {
|
|
110
110
|
const resolvedSkill = context?.skills.find(skill => skill.name === 'chatgpt-browser-worker');
|
|
111
|
-
|
|
111
|
+
// Once a worker receives a resolved execution context, that context owns
|
|
112
|
+
// skill discovery. The explicit path remains the fallback for direct
|
|
113
|
+
// adapter use, where no shared context has been resolved yet.
|
|
114
|
+
const candidate = resolvedSkill
|
|
115
|
+
? join(dirname(resolvedSkill.path), 'agents', 'browser-worker.agent.md')
|
|
116
|
+
: explicit;
|
|
112
117
|
if (!candidate)
|
|
113
118
|
throw new Error('chatgpt-browser-worker is not visible in the resolved execution context');
|
|
114
119
|
try {
|
package/dist/cli.js
CHANGED
|
@@ -14,7 +14,7 @@ import { NatsEventBus, eventFor } from './events.js';
|
|
|
14
14
|
import { serveDashboard } from './dashboard.js';
|
|
15
15
|
import { codexAndZaiUsageRegistry } from './usage.js';
|
|
16
16
|
import { discoverAgents } from './registry.js';
|
|
17
|
-
import { AgentObjectivePlanner, CommandObjectivePlanner } from './planner.js';
|
|
17
|
+
import { AgentObjectivePlanner, CommandObjectivePlanner, orchestratorPlanningDecision } from './planner.js';
|
|
18
18
|
import { extendRetryBudget } from './scheduler.js';
|
|
19
19
|
import { resolveExecutionContext } from './execution-context.js';
|
|
20
20
|
import { defaultWorkspaceRoot, resolveManagedProjectPath } from './workspace.js';
|
|
@@ -270,7 +270,7 @@ export async function ensureManagedGitHubJob(client, repositoryName, trustedAuth
|
|
|
270
270
|
if (!(error instanceof Error) || !error.message.includes('has no durable'))
|
|
271
271
|
throw error;
|
|
272
272
|
const now = new Date().toISOString();
|
|
273
|
-
job = { id: options.id, title: options.title || pr.title || 'Agents Relay job', objective: options.objective || pr.body || undefined, description: pr.body || undefined, priority: options.priority ?? 'P2', executionMode: options.executionMode ?? 'fixed', autoMerge: options.autoMerge ?? true, prNumber: pr.number, repository: repositoryName, state: 'OPEN', continuation: options.continuation ?? null, createdAt: now, updatedAt: now, tasks: [] };
|
|
273
|
+
job = { id: options.id, title: options.title || pr.title || 'Agents Relay job', objective: options.objective || pr.body || undefined, description: pr.body || undefined, priority: options.priority ?? 'P2', executionMode: options.executionMode ?? 'fixed', autoMerge: options.autoMerge ?? true, prNumber: pr.number, repository: repositoryName, state: 'OPEN', continuation: options.continuation ?? null, initialPlanning: options.initialTasks?.length ? { status: 'pending', decisionId: 'planner-0' } : undefined, createdAt: now, updatedAt: now, tasks: [] };
|
|
274
274
|
}
|
|
275
275
|
if (!job.description && pr.body)
|
|
276
276
|
job.description = pr.body;
|
|
@@ -286,9 +286,40 @@ export async function ensureManagedGitHubJob(client, repositoryName, trustedAuth
|
|
|
286
286
|
job.priority = 'P2';
|
|
287
287
|
if (!job.title && pr.title)
|
|
288
288
|
job.title = pr.title;
|
|
289
|
-
|
|
289
|
+
if (options.initialTasks?.length) {
|
|
290
|
+
const decisionId = job.initialPlanning?.decisionId ?? 'planner-0';
|
|
291
|
+
job.initialPlanning = { status: 'pending', decisionId };
|
|
292
|
+
job.updatedAt = new Date().toISOString();
|
|
293
|
+
await store.saveJob(job);
|
|
294
|
+
const decision = orchestratorPlanningDecision(job, options.initialTasks, decisionId, null, 0);
|
|
295
|
+
if (!(await store.load(options.id)).tasks.some(task => task.id === decision.id))
|
|
296
|
+
await store.appendTask(decision);
|
|
297
|
+
for (const original of options.initialTasks) {
|
|
298
|
+
const task = { ...original, parentTaskId: decision.id, planningDecisionId: decision.id, planningSource: 'orchestrator', planningRound: 0 };
|
|
299
|
+
if (!(await store.load(options.id)).tasks.some(item => item.id === task.id))
|
|
300
|
+
await store.appendTask(task);
|
|
301
|
+
}
|
|
302
|
+
job.initialPlanning = { status: 'complete', decisionId };
|
|
303
|
+
job.updatedAt = new Date().toISOString();
|
|
304
|
+
await store.saveJob(job);
|
|
305
|
+
}
|
|
306
|
+
else
|
|
307
|
+
await store.saveJob(job);
|
|
290
308
|
return { job: await store.load(options.id), pr };
|
|
291
309
|
}
|
|
310
|
+
async function appendPlannedTask(store, job, task) {
|
|
311
|
+
const existing = job.tasks.find(item => item.id === task.id);
|
|
312
|
+
if (existing)
|
|
313
|
+
return existing;
|
|
314
|
+
const decisionId = `plan-${task.id}`;
|
|
315
|
+
const decision = orchestratorPlanningDecision(job, [task], decisionId, task.parentTaskId, job.tasks.filter(item => item.kind === 'planner').length, 'orchestrator');
|
|
316
|
+
if (!job.tasks.some(item => item.id === decision.id))
|
|
317
|
+
await store.appendTask(decision);
|
|
318
|
+
const planned = { ...task, parentTaskId: task.parentTaskId ?? null, planningDecisionId: decision.id, planningSource: 'orchestrator', planningRound: decision.planningRound };
|
|
319
|
+
await store.appendTask(planned);
|
|
320
|
+
job.tasks.push(planned, decision);
|
|
321
|
+
return planned;
|
|
322
|
+
}
|
|
292
323
|
function workerTaskFromArgs(job, args, now, requireInput = false) {
|
|
293
324
|
const model = arg(args, '--model');
|
|
294
325
|
const adapterValue = arg(args, '--adapter');
|
|
@@ -438,12 +469,12 @@ async function runJobCommand(action, args) {
|
|
|
438
469
|
if (!id)
|
|
439
470
|
throw new Error('--id is required for managed GitHub jobs');
|
|
440
471
|
const start = hasArg(args, '--start');
|
|
472
|
+
const initialTask = start ? workerTaskFromArgs({ id, title: '', executionMode: 'fixed', prNumber: 0, repository: repo, state: 'OPEN', continuation: null, createdAt: '', updatedAt: '', tasks: [] }, args, new Date().toISOString(), true) : undefined;
|
|
441
473
|
if (start && action !== 'create')
|
|
442
474
|
throw new Error('--start is only supported by job create');
|
|
443
475
|
if (start) {
|
|
444
476
|
if (!hasArg(args, '--adapter'))
|
|
445
477
|
throw new Error('job create --start requires --adapter codex|chatgpt');
|
|
446
|
-
workerTaskFromArgs({ id, title: '', executionMode: 'fixed', prNumber: 0, repository: repo, state: 'OPEN', continuation: null, createdAt: '', updatedAt: '', tasks: [] }, args, new Date().toISOString(), true);
|
|
447
478
|
}
|
|
448
479
|
const auth = await githubAuthContext(args);
|
|
449
480
|
const client = auth.client;
|
|
@@ -465,18 +496,11 @@ async function runJobCommand(action, args) {
|
|
|
465
496
|
prNumber,
|
|
466
497
|
head: arg(args, '--head') || undefined,
|
|
467
498
|
base: arg(args, '--base', 'main'),
|
|
468
|
-
body: arg(args, '--body') || undefined
|
|
499
|
+
body: arg(args, '--body') || undefined,
|
|
500
|
+
initialTasks: initialTask ? [initialTask] : undefined
|
|
469
501
|
});
|
|
470
|
-
if (start)
|
|
471
|
-
|
|
472
|
-
const existing = result.job.tasks.find(item => item.id === task.id);
|
|
473
|
-
const store = new GitHubStore(client, repo, result.pr.number, trustedAuthors);
|
|
474
|
-
await store.appendTask(task);
|
|
475
|
-
if (!existing) {
|
|
476
|
-
await publishWake(eventBus(args), result.job, task.id, `Task ${task.id} submitted`);
|
|
477
|
-
result.job.tasks.push(task);
|
|
478
|
-
}
|
|
479
|
-
}
|
|
502
|
+
if (start)
|
|
503
|
+
await publishWake(eventBus(args), result.job, initialTask?.id ?? 'initial', `Initial planning decision and task ${initialTask?.id ?? 'initial'} recorded`);
|
|
480
504
|
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));
|
|
481
505
|
}
|
|
482
506
|
export function runtimePlanner(args) { const plannerCommand = arg([...args], '--planner-command'); const modelRuntime = arg([...args], '--codex', 'codex'); return plannerCommand ? new CommandObjectivePlanner(plannerCommand) : new AgentObjectivePlanner(modelRuntime); }
|
|
@@ -621,8 +645,7 @@ async function main() {
|
|
|
621
645
|
throw new Error('record requires --summary');
|
|
622
646
|
const commit = arg(args, '--commit');
|
|
623
647
|
const task = { jobId: job.id, id, projectName: arg(args, '--project') || undefined, agentName: arg(args, '--agent', 'orchestrator'), parentTaskId: arg(args, '--parent') || null, dependencies: arg(args, '--deps').split(',').filter(Boolean), capabilities: [], adapter: 'orchestrator', input: arg(args, '--input', summary), continuation: null, continuationDeliveredAt: null, state: 'SUCCEEDED', attempt: 1, maxAttempts: 1, leaseOwner: null, leaseExpiresAt: null, executionId: commit || null, threadId: null, result: { summary, data: commit ? { commit } : undefined }, error: null, timeoutMs: 0, createdAt: now, updatedAt: now };
|
|
624
|
-
await store
|
|
625
|
-
job.tasks.push(task);
|
|
648
|
+
await appendPlannedTask(store, job, task);
|
|
626
649
|
await saveLocal(localFile, job);
|
|
627
650
|
console.log(`Recorded ${id}`);
|
|
628
651
|
return;
|
|
@@ -631,16 +654,15 @@ async function main() {
|
|
|
631
654
|
const now = new Date().toISOString();
|
|
632
655
|
const task = workerTaskFromArgs(job, args, now);
|
|
633
656
|
task.id = arg(args, '--task-id', randomUUID());
|
|
634
|
-
await store
|
|
635
|
-
job.tasks.push(task);
|
|
657
|
+
const planned = await appendPlannedTask(store, job, task);
|
|
636
658
|
if (job.state !== 'RUNNING') {
|
|
637
659
|
job.state = 'RUNNING';
|
|
638
660
|
job.updatedAt = now;
|
|
639
661
|
await store.saveJob(job);
|
|
640
662
|
}
|
|
641
663
|
await saveLocal(localFile, job);
|
|
642
|
-
await publishWake(bus, job,
|
|
643
|
-
console.log(`Submitted ${
|
|
664
|
+
await publishWake(bus, job, planned.id, `Planning decision recorded and task ${planned.id} submitted`);
|
|
665
|
+
console.log(`Submitted ${planned.id}`);
|
|
644
666
|
return;
|
|
645
667
|
}
|
|
646
668
|
if (command === 'status') {
|
package/dist/dashboard.js
CHANGED
|
@@ -240,7 +240,7 @@ function renderJobList(){jobList.replaceChildren();const visible=filteredJobs();
|
|
|
240
240
|
function taskNode(task,children,depth){const li=element('li');const row=document.createElement('button');row.type='button';row.className='task-row';row.setAttribute('aria-current',String(task.id===selectedTaskId));row.title=task.id;row.addEventListener('click',()=>{selectedTaskId=task.id;eventTaskId=task.id;rememberSelection();clearEvents();renderAll();void loadEventHistory();});const id=element('span','task-id',text(task.id));id.style.paddingLeft=Math.min(depth,4)*6+'px';row.append(id,element('span','badge '+value(task.state),text(task.state)));li.append(row);const nested=children.get(task.id)||[];if(nested.length){const ul=element('ul','tree');for(const child of nested)ul.append(taskNode(child,children,depth+1));li.append(ul);}return li;}
|
|
241
241
|
function buildTree(tasks){taskTree.replaceChildren();if(!Array.isArray(tasks)||!tasks.length){taskTree.append(element('li','empty',text('No tasks in durable state.')));return;}const known=new Set(tasks.map(task=>task.id));const children=new Map();const roots=[];for(const task of tasks){if(!task.parentTaskId||!known.has(task.parentTaskId))roots.push(task);else{const siblings=children.get(task.parentTaskId)||[];siblings.push(task);children.set(task.parentTaskId,siblings);}}for(const task of roots)taskTree.append(taskNode(task,children,0));}
|
|
242
242
|
function attemptBudget(task){const attempt=Number(task?.attempt)||0;const max=Number(task?.maxAttempts)||0;if(max>=3&&max%3===0){const effectiveMax=Math.max(max,Math.ceil(attempt/3)*3);return attempt+' / 3×'+(effectiveMax/3);}return attempt+' / '+max;}
|
|
243
|
-
function renderTaskDetail(task){taskDetailRoot.replaceChildren();if(!task){taskDetailRoot.append(element('p','empty',text('Select a task to inspect execution details.')));return;}const routing=task.routing||{};const groups=element('div','detail-groups');groups.append(field('Task',task.id),field('Kind',task.kind||'work'),field('State',task.state),field('Project / agent',value(task.projectName)+' / '+value(task.agentName)),field('Parent task',task.parentTaskId||'root'),field('Adapter',task.adapter),field('Provider / model',value(routing.provider)+' / '+value(routing.model)),field('Reasoning / profile',value(routing.reasoning||routing.profile)),field('Attempt',attemptBudget(task)),field('Dependencies',Array.isArray(task.dependencies)&&task.dependencies.length?task.dependencies.join(', '):'none'),chatField(task));if(task.plannerResult)groups.append(field('Objective status',task.plannerResult.objective_status),markdownField('Assessment',task.plannerResult.assessment));if(task.error)groups.append(field('Error',task.error));taskDetailRoot.append(groups);}
|
|
243
|
+
function renderTaskDetail(task){taskDetailRoot.replaceChildren();if(!task){taskDetailRoot.append(element('p','empty',text('Select a task to inspect execution details.')));return;}const routing=task.routing||{};const groups=element('div','detail-groups');groups.append(field('Task',task.id),field('Kind',task.kind||'work'),field('State',task.state),field('Planning decision',task.planningDecisionId||'—'),field('Planning source',task.planningSource||'—'),field('Planning round',task.planningRound===undefined?'—':String(task.planningRound)),field('Project / agent',value(task.projectName)+' / '+value(task.agentName)),field('Parent task',task.parentTaskId||'root'),field('Adapter',task.adapter),field('Provider / model',value(routing.provider)+' / '+value(routing.model)),field('Reasoning / profile',value(routing.reasoning||routing.profile)),field('Attempt',attemptBudget(task)),field('Dependencies',Array.isArray(task.dependencies)&&task.dependencies.length?task.dependencies.join(', '):'none'),chatField(task));if(task.plannerResult)groups.append(field('Objective status',task.plannerResult.objective_status),markdownField('Assessment',task.plannerResult.assessment));if(task.error)groups.append(field('Error',task.error));taskDetailRoot.append(groups);}
|
|
244
244
|
function reviewStatus(job){const review=job?.review;if(!review)return 'Not started';if(review.outcome==='approved'&&review.headSha===job.headSha)return 'MERGE READY · approved';const stale=Array.isArray(job?.tasks)&&job.tasks.some(task=>task.agentName==='reviewer'&&task.reviewHeadSha&&job.headSha&&task.reviewHeadSha!==job.headSha);if(stale)return 'STALE · awaiting review';return String(review.outcome||'pending').replace('_',' ').toUpperCase();}
|
|
245
245
|
function renderSelectedJob(){jobDetail.replaceChildren();const job=selected();if(!job){selectedJobTitle.textContent='';jobDetail.append(element('div','empty',text('No durable jobs discovered.')));buildTree([]);renderTaskDetail(null);return;}selectedJobTitle.textContent='— '+value(job.title||job.id);if(job.id!==selectedJob){selectedJob=job.id;selectedTaskId=null;eventTaskId=null;clearEvents();rememberSelection();}const summary=element('div','job-summary');summary.append(field('GitHub state',job.githubState||'UNKNOWN'),field('Durable state',job.state),field('Execution mode',job.executionMode||'fixed'),field('Status',awaitingInitialTask(job)?'Awaiting initial task':job.state),field('Review',reviewStatus(job)),field('Review head SHA',job.review?.headSha||job.headSha||'—'),field('Objective',job.objective||job.description||job.title),prField(job),field('Repository',job.repository||'—'),field('Last activity',relativeTime(job.updatedAt)),field('Task states',compactCounts(job.tasks)));jobDetail.append(summary);const selectedTask=(job.tasks||[]).find(task=>task.id===selectedTaskId)||null;if(selectedTaskId&&!selectedTask){selectedTaskId=null;eventTaskId=null;clearEvents();rememberSelection();}buildTree(job.tasks||[]);renderTaskDetail(selectedTask);renderEventScope();}
|
|
246
246
|
function renderAll(){jobs.sort(compareJobs);syncFilters();renderJobList();renderSelectedJob();}
|
package/dist/planner.js
CHANGED
|
@@ -4,6 +4,17 @@ import { fileURLToPath } from 'node:url';
|
|
|
4
4
|
import { dirname, join } from 'node:path';
|
|
5
5
|
import { taskOverallTimeoutMs } from './types.js';
|
|
6
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
|
+
}
|
|
7
18
|
function record(value) {
|
|
8
19
|
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
9
20
|
throw new Error('Planner result must be an object');
|
package/dist/reconciler.js
CHANGED
|
@@ -13,17 +13,20 @@ export function managedWorkerInput(job, task, context) {
|
|
|
13
13
|
if (!task.output)
|
|
14
14
|
throw new Error(`Task ${task.id} requires an explicit output contract`);
|
|
15
15
|
const prUrl = `https://github.com/${job.repository}/pull/${job.prNumber}`;
|
|
16
|
+
const taskUrl = task.taskUrl ?? `${prUrl}#issuecomment-${task.id}`;
|
|
16
17
|
const output = task.output.kind === 'file'
|
|
17
18
|
? `file\nPath: ${task.output.path}`
|
|
18
19
|
: `task/PR\nPR: ${prUrl}\nTask: ${task.id}`;
|
|
19
20
|
return `[Agents Relay managed task]
|
|
20
|
-
|
|
21
|
-
Job: ${job.id}
|
|
22
|
-
Task: ${
|
|
23
|
-
|
|
21
|
+
Job URL: ${prUrl}
|
|
22
|
+
Job ID: ${job.id}
|
|
23
|
+
Task URL: ${taskUrl}
|
|
24
|
+
Task ID: ${task.id}
|
|
24
25
|
Project: ${task.projectName ?? context?.project.name ?? 'unspecified'}
|
|
25
26
|
|
|
26
|
-
|
|
27
|
+
Retrieve the durable job and task from GitHub before doing the work. Use whatever GitHub access this environment provides: the URLs, a GitHub integration/tool, or GitHub CLI/API. GitHub is the source of truth for task intent and declared output.
|
|
28
|
+
|
|
29
|
+
${context ? executionContextPrompt(context) + '\n\n' : ''}Follow the resolved project instructions and Agents Relay/events-bus worker contracts.
|
|
27
30
|
|
|
28
31
|
[Output contract]
|
|
29
32
|
${output}
|
|
@@ -126,12 +129,17 @@ export class Reconciler {
|
|
|
126
129
|
}
|
|
127
130
|
}
|
|
128
131
|
await this.recoverLostExecutions(job, now);
|
|
132
|
+
await this.recoverPlannerChildren(job);
|
|
129
133
|
for (const task of job.tasks)
|
|
130
134
|
if (task.state === 'CANCELLED') {
|
|
131
135
|
this.live.get(task.id)?.execution.cancel();
|
|
132
136
|
this.plannerLive.get(task.id)?.controller.abort();
|
|
133
137
|
}
|
|
134
138
|
let scheduled = schedule(await this.store.load(jobId));
|
|
139
|
+
if (prState === 'OPEN') {
|
|
140
|
+
await this.ensureFinalReview(scheduled, prState, headSha);
|
|
141
|
+
scheduled = schedule(await this.store.load(jobId));
|
|
142
|
+
}
|
|
135
143
|
if (prState === 'OPEN' && scheduled.executionMode === 'autonomous') {
|
|
136
144
|
scheduled = schedule(await this.maybeCreatePlanner(scheduled));
|
|
137
145
|
}
|
|
@@ -285,16 +293,24 @@ export class Reconciler {
|
|
|
285
293
|
async maybeCreatePlanner(job) {
|
|
286
294
|
if (job.executionMode !== 'autonomous')
|
|
287
295
|
return job;
|
|
296
|
+
if (job.initialPlanning?.status === 'pending')
|
|
297
|
+
return job;
|
|
288
298
|
const planners = job.tasks.filter(task => task.kind === 'planner').sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
|
|
289
299
|
if (planners.some(task => ['QUEUED', 'READY', 'RUNNING', 'WAITING'].includes(task.state)))
|
|
290
300
|
return job;
|
|
291
301
|
if (job.tasks.some(task => task.state === 'FAILED' || task.state === 'BLOCKED'))
|
|
292
302
|
return job;
|
|
303
|
+
if (job.tasks.length > 0 && !planners.some(task => task.state === 'SUCCEEDED'))
|
|
304
|
+
return job;
|
|
293
305
|
if (job.tasks.some(task => task.kind !== 'planner' && !['SUCCEEDED', 'CANCELLED'].includes(task.state)))
|
|
294
306
|
return job;
|
|
307
|
+
if (planners.length > 0 && planners.at(-1)?.plannerResult?.objective_status === 'in_progress' && !job.tasks.some(task => task.planningDecisionId === planners.at(-1)?.id))
|
|
308
|
+
return job;
|
|
295
309
|
const latest = planners.at(-1);
|
|
296
310
|
if (latest?.plannerResult?.objective_status === 'satisfied')
|
|
297
311
|
return job;
|
|
312
|
+
if (latest && job.review && job.review.outcome !== 'approved')
|
|
313
|
+
return job;
|
|
298
314
|
const used = new Set(job.tasks.map(task => task.id));
|
|
299
315
|
let number = planners.length + 1;
|
|
300
316
|
let id = `planner-${number}`;
|
|
@@ -303,11 +319,26 @@ export class Reconciler {
|
|
|
303
319
|
id = `planner-${number}`;
|
|
304
320
|
}
|
|
305
321
|
const now = new Date().toISOString();
|
|
306
|
-
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 };
|
|
322
|
+
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 };
|
|
307
323
|
await this.store.appendTask(task);
|
|
308
324
|
await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'planner.created', 'queued', `Planner task ${task.id} created`, 'orchestrator'));
|
|
309
325
|
return this.store.load(job.id);
|
|
310
326
|
}
|
|
327
|
+
async recoverPlannerChildren(job) {
|
|
328
|
+
for (const planner of job.tasks.filter(task => task.kind === 'planner' && task.state === 'SUCCEEDED' && task.plannerResult)) {
|
|
329
|
+
const children = planner.plannerResult?.next_tasks.map(spec => this.plannerChild(job, planner, spec)) ?? [];
|
|
330
|
+
for (const child of children) {
|
|
331
|
+
const existing = job.tasks.find(item => item.id === child.id);
|
|
332
|
+
if (existing) {
|
|
333
|
+
if (!samePlannerChildDefinition(existing, child))
|
|
334
|
+
throw new Error(`Planner task ${child.id} already exists with a different definition`);
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
await this.store.appendTask(child);
|
|
338
|
+
job.tasks.push(child);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
311
342
|
async resolvedContext(job, task, role) {
|
|
312
343
|
const projectPath = this.options.projectPathForJob ? await this.options.projectPathForJob(job) : task.routing?.cwd ?? process.cwd();
|
|
313
344
|
return resolveExecutionContext(projectPath, role, {
|
|
@@ -481,14 +512,15 @@ export class Reconciler {
|
|
|
481
512
|
}
|
|
482
513
|
try {
|
|
483
514
|
const result = parsePlannerResult(raw);
|
|
484
|
-
|
|
485
|
-
|
|
515
|
+
const children = result.next_tasks.map(spec => this.plannerChild(job, task, spec));
|
|
516
|
+
for (const child of children) {
|
|
486
517
|
const existing = job.tasks.find(item => item.id === child.id);
|
|
487
518
|
if (!existing)
|
|
488
|
-
|
|
519
|
+
continue;
|
|
489
520
|
else if (!samePlannerChildDefinition(existing, child))
|
|
490
521
|
throw new Error(`Planner task ${child.id} already exists with a different definition`);
|
|
491
522
|
}
|
|
523
|
+
// Persist the completed decision before exposing executable children.
|
|
492
524
|
task.plannerResult = result;
|
|
493
525
|
task.result = { summary: result.assessment, data: { objective_status: result.objective_status, next_tasks: result.next_tasks } };
|
|
494
526
|
task.error = null;
|
|
@@ -496,22 +528,32 @@ export class Reconciler {
|
|
|
496
528
|
task.leaseExpiresAt = null;
|
|
497
529
|
transitionTask(task, 'SUCCEEDED');
|
|
498
530
|
await this.store.saveTask(task);
|
|
531
|
+
for (const child of children)
|
|
532
|
+
if (!job.tasks.some(item => item.id === child.id))
|
|
533
|
+
await this.store.appendTask(child);
|
|
499
534
|
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 }));
|
|
500
535
|
}
|
|
501
536
|
catch (caught) {
|
|
502
537
|
const message = caught instanceof Error ? caught.message : String(caught);
|
|
503
|
-
task.
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
538
|
+
if (task.state === 'SUCCEEDED') {
|
|
539
|
+
task.error = `Child materialization failed: ${message}`;
|
|
540
|
+
await this.store.saveTask(task);
|
|
541
|
+
await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'planner.children_failed', 'failed', `Planner ${task.id} child materialization failed: ${message}`, 'user'));
|
|
542
|
+
}
|
|
543
|
+
else {
|
|
544
|
+
task.error = message;
|
|
545
|
+
task.leaseOwner = null;
|
|
546
|
+
task.leaseExpiresAt = null;
|
|
547
|
+
transitionTask(task, task.attempt < task.maxAttempts ? 'READY' : 'FAILED');
|
|
548
|
+
await this.store.saveTask(task);
|
|
549
|
+
await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'planner.failed', 'failed', `Planner ${task.id}: ${message}`, 'user'));
|
|
550
|
+
}
|
|
509
551
|
}
|
|
510
552
|
this.detach(this.reconcile(jobId), jobId, task.id, 'planner follow-up reconciliation');
|
|
511
553
|
}
|
|
512
554
|
plannerChild(job, planner, spec) {
|
|
513
555
|
const now = new Date().toISOString();
|
|
514
|
-
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 };
|
|
556
|
+
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 };
|
|
515
557
|
}
|
|
516
558
|
clearExecution(taskId, executionId) {
|
|
517
559
|
const live = this.live.get(taskId);
|
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
|
}
|
|
@@ -434,7 +434,7 @@ export class GitHubStore {
|
|
|
434
434
|
const all = [];
|
|
435
435
|
let cursor = null;
|
|
436
436
|
do {
|
|
437
|
-
const data = await this.graphql('query($owner:String!,$name:String!,$number:Int!,$cursor:String){repository(owner:$owner,name:$name){pullRequest(number:$number){comments(first:100,after:$cursor){nodes{id databaseId body updatedAt author{login}} pageInfo{hasNextPage endCursor}}}}}', { owner, name, number: this.prNumber, cursor });
|
|
437
|
+
const data = await this.graphql('query($owner:String!,$name:String!,$number:Int!,$cursor:String){repository(owner:$owner,name:$name){pullRequest(number:$number){comments(first:100,after:$cursor){nodes{id databaseId url body updatedAt author{login}} pageInfo{hasNextPage endCursor}}}}}', { owner, name, number: this.prNumber, cursor });
|
|
438
438
|
const repository = data.repository;
|
|
439
439
|
const pr = repository?.pullRequest;
|
|
440
440
|
const comments = pr?.comments;
|
|
@@ -444,6 +444,7 @@ export class GitHubStore {
|
|
|
444
444
|
all.push({
|
|
445
445
|
id: Number(value.databaseId ?? 0),
|
|
446
446
|
nodeId: typeof value.id === 'string' ? value.id : undefined,
|
|
447
|
+
url: typeof value.url === 'string' ? value.url : undefined,
|
|
447
448
|
body: String(value.body ?? ''),
|
|
448
449
|
updatedAt: String(value.updatedAt ?? ''),
|
|
449
450
|
authorLogin: typeof author?.login === 'string' ? author.login : null
|
|
@@ -486,7 +487,13 @@ export class GitHubStore {
|
|
|
486
487
|
if (!job)
|
|
487
488
|
throw new Error(`Managed GitHub job ${jobId} has no durable ${JOB_MARKER} marker in PR #${this.prNumber}; run agents-relay job adopt --repo ${this.repository} --pr ${this.prNumber} --id ${jobId}`);
|
|
488
489
|
const soleTrustedJob = jobs.length === 1;
|
|
489
|
-
job.tasks = comments.map(
|
|
490
|
+
job.tasks = comments.map(comment => {
|
|
491
|
+
const task = parseTask(comment.body);
|
|
492
|
+
if (!task)
|
|
493
|
+
return null;
|
|
494
|
+
const taskUrl = comment.url ?? (comment.id > 0 ? `https://github.com/${this.repository}/pull/${this.prNumber}#issuecomment-${comment.id}` : undefined);
|
|
495
|
+
return taskUrl ? { ...task, taskUrl } : task;
|
|
496
|
+
}).filter((x) => x !== null).filter(task => isTaskForJob(task, jobId, soleTrustedJob));
|
|
490
497
|
return job;
|
|
491
498
|
}
|
|
492
499
|
async listAgents() { return (await this.list()).filter(comment => this.isTrusted(comment)).map(x => parseAgent(x.body)).filter((x) => x !== null); }
|
package/package.json
CHANGED
|
@@ -96,6 +96,16 @@ ChatGPT thread identity is diagnostic only. It is not persisted as a resumable t
|
|
|
96
96
|
|
|
97
97
|
## Autonomous planner
|
|
98
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
|
+
|
|
99
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.
|
|
100
110
|
|
|
101
111
|
## Durable truth
|