agents-relay 2.0.53 → 2.0.55
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 +4 -0
- package/dist/cli-help.js +4 -1
- package/dist/cli-support.js +15 -11
- package/dist/cli.js +11 -4
- package/dist/dashboard.js +2 -0
- package/dist/reconciler-review.js +53 -64
- package/dist/reconciler.js +20 -14
- package/dist/relayd.js +18 -2
- package/dist/task-actions.js +23 -3
- package/dist/workspace.js +18 -0
- package/package.json +1 -1
- package/skills/agents-relay/agents/planner.agent.md +9 -0
- package/skills/agents-relay/contracts/task-job-contract.md +22 -0
package/README.md
CHANGED
|
@@ -4,6 +4,10 @@ Agents Relay is a generic TypeScript runtime for durable asynchronous agent jobs
|
|
|
4
4
|
|
|
5
5
|
V2 separates **operational execution state** from that collaboration record. A local RelayStore may use SQLite/WAL for runtime snapshots, event idempotency and a durable GitHub projection outbox. ProjectedDurableStore bootstraps from the existing GitHub representation, commits Job/Plan/Task runtime changes locally, and lets GitHubProjectionWriter project them asynchronously. GitHub therefore remains the human-readable durable Job/Plan/Task/Review surface without being required for every high-frequency runtime transition.
|
|
6
6
|
|
|
7
|
+
The canonical V2 architecture contract is
|
|
8
|
+
[DESIGN-PRINCIPLES.md](DESIGN-PRINCIPLES.md). README sections below describe
|
|
9
|
+
the runtime; they do not replace that contract.
|
|
10
|
+
|
|
7
11
|
## V2 architecture
|
|
8
12
|
|
|
9
13
|
```mermaid
|
package/dist/cli-help.js
CHANGED
|
@@ -123,9 +123,12 @@ Required: --task-id ID and the normal Job storage options.`,
|
|
|
123
123
|
'task list': `List durable Tasks for one Job.
|
|
124
124
|
|
|
125
125
|
Required: the normal Job storage options.`,
|
|
126
|
+
'task start': `Mark one orchestrator-owned self Task RUNNING when the creating orchestrator actually begins the work.
|
|
127
|
+
|
|
128
|
+
Required: --task-id ID and the normal Job storage options. The Task must use adapter=orchestrator and be QUEUED. Start must occur before task complete.`,
|
|
126
129
|
'task complete': `Complete one orchestrator-owned Task after the creating orchestrator has finished the work.
|
|
127
130
|
|
|
128
|
-
Required: --task-id ID, --summary TEXT, and the normal Job storage options. The Task must use adapter=orchestrator and be
|
|
131
|
+
Required: --task-id ID, --summary TEXT, and the normal Job storage options. The Task must use adapter=orchestrator and already be RUNNING. This command is for the same orchestrator turn that created the self-task; it is not a deferred handoff mechanism.`,
|
|
129
132
|
'task retry': `Retry one durable Task.
|
|
130
133
|
|
|
131
134
|
Required: --task-id ID and the normal Job storage options.`,
|
package/dist/cli-support.js
CHANGED
|
@@ -162,20 +162,24 @@ export async function publishTaskUnblocked(bus, job, taskId, reason) { if (!bus)
|
|
|
162
162
|
catch (error) {
|
|
163
163
|
process.stderr.write(`warning: unblock event unavailable: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
164
164
|
} }
|
|
165
|
+
export async function publishOrchestratorTaskStarted(bus, job, task) {
|
|
166
|
+
if (!bus)
|
|
167
|
+
return;
|
|
168
|
+
try {
|
|
169
|
+
await bus.publish(eventFor(job.id, task.id, task.parentTaskId, 'task.started', 'running', `Task ${task.id} started by orchestrator`, 'orchestrator', { adapter: 'orchestrator' }, { agent: 'relay', component: 'agents-relay' }));
|
|
170
|
+
}
|
|
171
|
+
catch (error) {
|
|
172
|
+
process.stderr.write(`warning: orchestrator task start event unavailable for ${job.id}/${task.id}: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
165
175
|
export async function publishOrchestratorTaskCompletion(bus, job, task, summary) {
|
|
166
176
|
if (!bus)
|
|
167
177
|
return;
|
|
168
|
-
|
|
169
|
-
eventFor(job.id, task.id, task.parentTaskId, 'task.
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
try {
|
|
174
|
-
await bus.publish(event);
|
|
175
|
-
}
|
|
176
|
-
catch (error) {
|
|
177
|
-
process.stderr.write(`warning: orchestrator task event unavailable for ${job.id}/${task.id}: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
178
|
-
}
|
|
178
|
+
try {
|
|
179
|
+
await bus.publish(eventFor(job.id, task.id, task.parentTaskId, 'task.completed', 'succeeded', `Task ${task.id} completed by orchestrator`, 'orchestrator', { adapter: 'orchestrator', summary }, { agent: 'relay', component: 'agents-relay' }));
|
|
180
|
+
}
|
|
181
|
+
catch (error) {
|
|
182
|
+
process.stderr.write(`warning: orchestrator task completion event unavailable for ${job.id}/${task.id}: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
179
183
|
}
|
|
180
184
|
}
|
|
181
185
|
export async function publishDependentUnblocked(bus, targets, reason) { if (!bus)
|
package/dist/cli.js
CHANGED
|
@@ -18,7 +18,7 @@ import { decisionMode, LayaCliDecisionEngine } from './decision.js';
|
|
|
18
18
|
import { discoverAgents } from './registry.js';
|
|
19
19
|
import { AgentObjectivePlanner, CommandObjectivePlanner } from './planner.js';
|
|
20
20
|
import { transitionJobEvent } from './scheduler.js';
|
|
21
|
-
import { applyOrchestratorCompleteAction, applyTaskHoldAction, dispatchTaskAction } from './task-actions.js';
|
|
21
|
+
import { applyOrchestratorStartAction, applyOrchestratorCompleteAction, applyTaskHoldAction, dispatchTaskAction } from './task-actions.js';
|
|
22
22
|
import { applyJobActivateAction, applyJobCancelAction } from './job-actions.js';
|
|
23
23
|
import { resolveExecutionContext } from './execution-context.js';
|
|
24
24
|
import { defaultWorkspaceRoot, resolveManagedProjectPath, resolveRepositoryProjectPath } from './workspace.js';
|
|
@@ -30,7 +30,7 @@ import { parseAgentGraph } from './agent-graph.js';
|
|
|
30
30
|
import { runJobUnblockCommand, runTaskUnblockCommand } from './cli-unblock.js';
|
|
31
31
|
import { createJobEntity, createTaskEntity } from './workflow/entity-creation.js';
|
|
32
32
|
export { helpText } from './cli-help.js';
|
|
33
|
-
import { SERVICE_DEFAULTS, ServiceEventBus, arg, hasArg, textOption, integerArg, repository, storeFor, saveLocal, githubStore, eventBus, publishWake, publishDependentUnblocked, priority, storySize, executionMode, chatGptTabClosePolicy, requestedExecutionMode, continuation, scheduleFromArgs, jobTagFromArgs, requestManagedReconciliation, relayControlJob, relayTaskCommand, withEventBus
|
|
33
|
+
import { SERVICE_DEFAULTS, ServiceEventBus, arg, hasArg, textOption, integerArg, repository, storeFor, saveLocal, githubStore, eventBus, publishWake, publishDependentUnblocked, priority, storySize, executionMode, chatGptTabClosePolicy, requestedExecutionMode, continuation, scheduleFromArgs, jobTagFromArgs, requestManagedReconciliation, relayControlJob, relayTaskCommand, withEventBus } from './cli-support.js';
|
|
34
34
|
export { SERVICE_DEFAULTS, eventBus, executionMode, chatGptTabClosePolicy } from './cli-support.js';
|
|
35
35
|
export async function ensureManagedGitHubJob(client, repositoryName, trustedAuthors, options) {
|
|
36
36
|
if (!options.id)
|
|
@@ -157,10 +157,10 @@ async function runManagedTaskCommand(action, args) {
|
|
|
157
157
|
console.log(JSON.stringify(task, null, 2));
|
|
158
158
|
}
|
|
159
159
|
async function runTaskCommand(action, args) {
|
|
160
|
-
if (!['create', 'update', 'show', 'list', 'complete', 'retry', 'unblock', 'cancel'].includes(action))
|
|
160
|
+
if (!['create', 'update', 'show', 'list', 'start', 'complete', 'retry', 'unblock', 'cancel'].includes(action))
|
|
161
161
|
throw new Error(usage);
|
|
162
162
|
const managed = Boolean(arg(args, '--repo') && arg(args, '--pr') && !arg(args, '--file'));
|
|
163
|
-
if (managed && ['create', 'complete', 'retry', 'cancel'].includes(action)) {
|
|
163
|
+
if (managed && ['create', 'start', 'complete', 'retry', 'cancel'].includes(action)) {
|
|
164
164
|
await runManagedTaskCommand(action, args);
|
|
165
165
|
return;
|
|
166
166
|
}
|
|
@@ -201,6 +201,13 @@ async function runTaskCommand(action, args) {
|
|
|
201
201
|
await runTaskUnblockCommand(args, store, job, task, localFile);
|
|
202
202
|
return;
|
|
203
203
|
}
|
|
204
|
+
if (action === 'start') {
|
|
205
|
+
await applyOrchestratorStartAction(task);
|
|
206
|
+
await store.saveTask(task);
|
|
207
|
+
await saveLocal(localFile, await store.load(job.id));
|
|
208
|
+
console.log(JSON.stringify(task, null, 2));
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
204
211
|
if (action === 'complete') {
|
|
205
212
|
const summary = arg(args, '--summary');
|
|
206
213
|
if (!summary)
|
package/dist/dashboard.js
CHANGED
|
@@ -280,6 +280,8 @@ export function serveDashboard(store, port = 8787, bus, refreshMs = 300000, jobI
|
|
|
280
280
|
let result;
|
|
281
281
|
if (body.action === 'create' && body.task && resolvedActions?.taskCreate)
|
|
282
282
|
result = await resolvedActions.taskCreate(requestedJobId, body.task);
|
|
283
|
+
else if (body.action === 'start' && isTaskId(requestedTaskId) && resolvedActions?.taskStart)
|
|
284
|
+
result = await resolvedActions.taskStart(requestedJobId, requestedTaskId);
|
|
283
285
|
else if (body.action === 'retry' && isTaskId(requestedTaskId) && resolvedActions?.taskRetry)
|
|
284
286
|
result = await resolvedActions.taskRetry(requestedJobId, requestedTaskId);
|
|
285
287
|
else if (body.action === 'cancel' && isTaskId(requestedTaskId) && resolvedActions?.taskCancel)
|
|
@@ -4,10 +4,9 @@ import { implementationComplete, implementationTasks } from './runtime-status.js
|
|
|
4
4
|
import { eventFor } from './events.js';
|
|
5
5
|
import { workerCandidates } from './worker-routing.js';
|
|
6
6
|
import { routeExecution } from './execution-routing.js';
|
|
7
|
-
import { ReconcilerRuntimeBase
|
|
7
|
+
import { ReconcilerRuntimeBase } from './reconciler-runtime.js';
|
|
8
8
|
import { dispatchReviewLifecycle, reviewOutcome } from './workflow/review-v2.js';
|
|
9
9
|
import { createTaskEntity } from './workflow/entity-creation.js';
|
|
10
|
-
const taskRouteClaims = new Set();
|
|
11
10
|
export class ReconcilerReviewBase extends ReconcilerRuntimeBase {
|
|
12
11
|
async currentHeadSha(job) { return await this.store.headSha?.() ?? job.headSha ?? job.review?.headSha ?? null; }
|
|
13
12
|
reviewFacts(job, prState, headSha) {
|
|
@@ -159,72 +158,62 @@ export class ReconcilerReviewBase extends ReconcilerRuntimeBase {
|
|
|
159
158
|
reason: 'worker-router',
|
|
160
159
|
};
|
|
161
160
|
}
|
|
162
|
-
async
|
|
161
|
+
async routeTaskForExecution(job, task) {
|
|
162
|
+
if (task.routing)
|
|
163
|
+
return { task, routed: false };
|
|
164
|
+
if (!task.output || task.adapter === 'orchestrator')
|
|
165
|
+
return { task, routed: false };
|
|
163
166
|
const usage = this.options.usageSnapshotProvider ? await this.options.usageSnapshotProvider() : undefined;
|
|
164
167
|
const registered = await this.store.listAgents();
|
|
165
168
|
const candidates = workerCandidates(this.options.adapters, registered, usage);
|
|
166
169
|
const runtimeSummary = this.options.runtimeSummaryProvider?.();
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
task.workerSelection = routed.selection;
|
|
215
|
-
task.adapter = routed.candidate.adapter;
|
|
216
|
-
task.routing = routed.candidate.routing;
|
|
217
|
-
task.timeoutMs = task.timeoutMs || taskOverallTimeoutMs(task.storySize ?? 'M', task.routing.reasoning);
|
|
218
|
-
task.updatedAt = new Date().toISOString();
|
|
219
|
-
await this.store.saveTask(task);
|
|
220
|
-
await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'worker.selected', 'queued', `Worker ${routed.selection.workerId} selected for task ${task.id}`, 'user', { workerSelection: routed.selection, workerRequirements: requirements }, { agent: 'relay', component: 'agents-relay' }));
|
|
221
|
-
await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'model.selected', 'queued', `Selected ${task.routing.model} · thinking ${task.routing.reasoning ?? 'unknown'}`, 'user', { routing: task.routing }, { agent: 'relay', component: 'agents-relay' }));
|
|
222
|
-
});
|
|
223
|
-
}
|
|
224
|
-
finally {
|
|
225
|
-
taskRouteClaims.delete(claimKey);
|
|
226
|
-
}
|
|
227
|
-
}
|
|
170
|
+
const requirements = task.workerRequirements ?? { capabilities: [] };
|
|
171
|
+
const taskFacts = {
|
|
172
|
+
job: {
|
|
173
|
+
id: job.id, title: job.title, objective: job.objective ?? null,
|
|
174
|
+
description: job.description ?? null, priority: job.priority ?? null,
|
|
175
|
+
executionMode: job.executionMode, repository: job.repository,
|
|
176
|
+
prNumber: job.prNumber, state: job.state,
|
|
177
|
+
},
|
|
178
|
+
task: {
|
|
179
|
+
id: task.id, kind: task.kind ?? 'work', storySize: task.storySize ?? 'M',
|
|
180
|
+
priority: task.priority ?? null, agentName: task.agentName ?? null,
|
|
181
|
+
parentTaskId: task.parentTaskId, dependencies: task.dependencies,
|
|
182
|
+
capabilities: task.capabilities, input: task.input,
|
|
183
|
+
attachments: task.attachments ?? [], output: task.output ?? null,
|
|
184
|
+
state: task.state, attempt: task.attempt, maxAttempts: task.maxAttempts,
|
|
185
|
+
},
|
|
186
|
+
storySize: task.storySize ?? 'M',
|
|
187
|
+
};
|
|
188
|
+
const routingConstraints = undefined;
|
|
189
|
+
const parent = task.agentName === 'troubleshooter' && task.parentTaskId
|
|
190
|
+
? job.tasks.find(item => item.id === task.parentTaskId)
|
|
191
|
+
: undefined;
|
|
192
|
+
const parentRouteFailedBeforeStart = Boolean(parent?.routing && /exited before publishing task\.started|worker_exit_before_start|launch failed/i.test(parent.error ?? parent.failureReason ?? ''));
|
|
193
|
+
const excludedRoutes = parentRouteFailedBeforeStart && parent?.routing
|
|
194
|
+
? [{ provider: parent.routing.provider, model: parent.routing.model, profile: parent.routing.profile }]
|
|
195
|
+
: [];
|
|
196
|
+
const routed = await routeExecution(candidates, requirements, {
|
|
197
|
+
decisionEngine: this.options.decisionEngine,
|
|
198
|
+
decisionMode: this.options.decisionMode,
|
|
199
|
+
taskFacts: { ...taskFacts, recovery_parent_failed_route: excludedRoutes[0] ?? null },
|
|
200
|
+
usage,
|
|
201
|
+
registered,
|
|
202
|
+
chatgptRunning: runtimeSummary?.chatgptRunning ?? 0,
|
|
203
|
+
routingConstraints,
|
|
204
|
+
excludedRoutes,
|
|
205
|
+
auditStore: this.options.decisionAuditStore,
|
|
206
|
+
auditTask: task,
|
|
207
|
+
});
|
|
208
|
+
task.workerSelection = routed.selection;
|
|
209
|
+
task.adapter = routed.candidate.adapter;
|
|
210
|
+
task.routing = routed.candidate.routing;
|
|
211
|
+
task.timeoutMs = task.timeoutMs || taskOverallTimeoutMs(task.storySize ?? 'M', task.routing.reasoning);
|
|
212
|
+
task.updatedAt = new Date().toISOString();
|
|
213
|
+
await this.store.saveTask(task);
|
|
214
|
+
await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'worker.selected', 'queued', 'Worker ' + routed.selection.workerId + ' selected for task ' + task.id, 'user', { workerSelection: routed.selection, workerRequirements: requirements }, { agent: 'relay', component: 'agents-relay' }));
|
|
215
|
+
await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'model.selected', 'queued', 'Selected ' + task.routing.model + ' · thinking ' + (task.routing.reasoning ?? 'unknown'), 'user', { routing: task.routing }, { agent: 'relay', component: 'agents-relay' }));
|
|
216
|
+
return { task, routed: true };
|
|
228
217
|
}
|
|
229
218
|
async applyReviewEvent(event) {
|
|
230
219
|
const job = await this.store.load(event.job_id);
|
package/dist/reconciler.js
CHANGED
|
@@ -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';
|
|
@@ -100,7 +100,6 @@ export class Reconciler extends ReconcilerReviewBase {
|
|
|
100
100
|
}, context => `effect.mergePullRequest:${context.jobId}:${String(context.data?.headSha ?? 'missing')}`));
|
|
101
101
|
}
|
|
102
102
|
async dispatchExecutableTasks(jobId) {
|
|
103
|
-
await this.routeExecutableTasks(await this.store.load(jobId));
|
|
104
103
|
const scheduled = schedule(await this.store.load(jobId));
|
|
105
104
|
for (const task of scheduled.tasks.filter(item => item.state === 'SUCCEEDED' && (item.continuation || scheduled.continuation) && !item.continuationDeliveredAt)) {
|
|
106
105
|
this.detach(this.deliverContinuation(scheduled, task), scheduled.id, task.id, 'continuation');
|
|
@@ -111,7 +110,6 @@ export class Reconciler extends ReconcilerReviewBase {
|
|
|
111
110
|
&& task.executionId === null
|
|
112
111
|
&& task.kind !== 'planner'
|
|
113
112
|
&& task.adapter !== 'orchestrator'
|
|
114
|
-
&& (!['codex', 'chatgpt'].includes(task.adapter) || Boolean(task.routing))
|
|
115
113
|
&& dependencyReady(task, new Map(scheduled.tasks.map(item => [item.id, item]))))
|
|
116
114
|
.sort((a, b) => compareTaskPriority(a, b, scheduled.priority ?? 'P2'))
|
|
117
115
|
.slice(0, capacity);
|
|
@@ -368,13 +366,6 @@ export class Reconciler extends ReconcilerReviewBase {
|
|
|
368
366
|
return withObservabilityCorrelation({ repository: job.repository, prNumber: job.prNumber, jobId: job.id, taskId: task.id }, () => this.launchCorrelated(job, task));
|
|
369
367
|
}
|
|
370
368
|
async launchCorrelated(job, task) {
|
|
371
|
-
const adapter = this.options.adapters.find(x => x.name === task.adapter);
|
|
372
|
-
if (!adapter) {
|
|
373
|
-
await transitionTaskEvent(task, 'fail');
|
|
374
|
-
task.error = `No adapter ${task.adapter}`;
|
|
375
|
-
await this.store.saveTask(task);
|
|
376
|
-
return;
|
|
377
|
-
}
|
|
378
369
|
if (['codex', 'chatgpt'].includes(task.adapter) && !this.options.eventBus) {
|
|
379
370
|
await transitionTaskEvent(task, 'block');
|
|
380
371
|
task.error = 'Model-backed tasks require an event bus for authoritative lifecycle state';
|
|
@@ -383,11 +374,17 @@ export class Reconciler extends ReconcilerReviewBase {
|
|
|
383
374
|
}
|
|
384
375
|
const claimedTask = await withExecutionBoundary(job.id, task.id, async () => {
|
|
385
376
|
const freshJob = await this.store.load(job.id);
|
|
386
|
-
|
|
377
|
+
let freshTask = freshJob.tasks.find(item => item.id === task.id);
|
|
387
378
|
if (!freshTask || freshTask.state !== 'QUEUED' || !dependencyReady(freshTask, new Map(freshJob.tasks.map(item => [item.id, item]))))
|
|
388
379
|
return;
|
|
389
380
|
if (freshTask.executionId !== null)
|
|
390
381
|
return;
|
|
382
|
+
if (!freshTask.routing && freshTask.adapter !== 'orchestrator') {
|
|
383
|
+
freshTask = (await this.routeTaskForExecution(freshJob, freshTask)).task;
|
|
384
|
+
}
|
|
385
|
+
if (['codex', 'chatgpt'].includes(freshTask.adapter) && !freshTask.routing) {
|
|
386
|
+
throw new Error('Task ' + freshTask.id + ' requires routing metadata before execution claim');
|
|
387
|
+
}
|
|
391
388
|
const leaseMs = Math.max(this.options.leaseMs ?? 300000, freshTask.timeoutMs);
|
|
392
389
|
const startedAt = (this.options.now ?? new Date()).getTime();
|
|
393
390
|
freshTask.attempt += 1;
|
|
@@ -405,12 +402,18 @@ export class Reconciler extends ReconcilerReviewBase {
|
|
|
405
402
|
if (!claimedTask)
|
|
406
403
|
return;
|
|
407
404
|
task = claimedTask;
|
|
405
|
+
const adapter = this.options.adapters.find(x => x.name === task.adapter);
|
|
406
|
+
if (!adapter) {
|
|
407
|
+
await this.failLaunch(job.id, task.id, task.executionId, 'No adapter ' + task.adapter);
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
408
410
|
let executionContext;
|
|
409
411
|
let managedHead = null;
|
|
410
412
|
try {
|
|
411
413
|
const role = task.agentName === 'reviewer' ? 'reviewer' : task.agentName === 'troubleshooter' ? 'troubleshooter' : task.parentTaskId ? 'nested-worker' : 'worker';
|
|
412
414
|
let projectPath = this.options.projectPathForJob ? await this.options.projectPathForJob(job) : task.routing?.cwd ?? process.cwd();
|
|
413
|
-
|
|
415
|
+
const canonicalProjectRun = taskUsesCanonicalProjectRun(task, job.tasks);
|
|
416
|
+
if (['codex', 'chatgpt'].includes(task.adapter) && this.store.pullRequestHead && task.executionId && !canonicalProjectRun) {
|
|
414
417
|
managedHead = await this.store.pullRequestHead();
|
|
415
418
|
if (!managedHead)
|
|
416
419
|
throw new Error(`PR #${job.prNumber} has no resolvable head ref`);
|
|
@@ -433,9 +436,12 @@ export class Reconciler extends ReconcilerReviewBase {
|
|
|
433
436
|
let execution;
|
|
434
437
|
const sourceId = executionEventSource(job.id, task.id, durableExecutionId);
|
|
435
438
|
const baseWorkerInput = await managedWorkerInput(job, task, executionContext);
|
|
439
|
+
const canonicalProjectRun = taskUsesCanonicalProjectRun(task, job.tasks);
|
|
436
440
|
const workerInput = managedHead
|
|
437
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.`
|
|
438
|
-
:
|
|
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;
|
|
439
445
|
const workerTask = { ...task, input: workerInput, routing: task.routing ? { ...task.routing, cwd: executionContext.project.path } : task.routing };
|
|
440
446
|
const startAckSetup = task.adapter === 'chatgpt' ? await this.startAck(job.id, task.id, sourceId, Date.now(), this.options.startAckTimeoutMs ?? chatGptStartAckTimeoutMs(task.timeoutMs)) : null;
|
|
441
447
|
const closePolicy = task.chatgptTabClosePolicy ?? this.options.chatgptTabClosePolicy ?? adapter.tabClosePolicy ?? 'after-start';
|
|
@@ -459,7 +465,7 @@ export class Reconciler extends ReconcilerReviewBase {
|
|
|
459
465
|
void (execution.launched ?? Promise.resolve()).then(() => this.emit(eventFor(job.id, task.id, task.parentTaskId, 'task.process.launched', 'running', `Task ${task.id} process launched`, 'user', { adapter: adapter.id, model: task.routing?.model }, { agent: 'relay', component: 'agents-relay' }))).catch(error => this.failLaunch(job.id, task.id, durableExecutionId, error instanceof Error ? error.message : String(error), 'launch'));
|
|
460
466
|
if (startAckSetup)
|
|
461
467
|
void startAckSetup.ack.finally(() => { void startAckSetup.stop(); }).catch(() => { });
|
|
462
|
-
this.live.set(task.id, { jobId: job.id, taskId: task.id, executionId: durableExecutionId, execution, controller, launchBarrier:
|
|
468
|
+
this.live.set(task.id, { jobId: job.id, taskId: task.id, executionId: durableExecutionId, execution, controller, launchBarrier: startedAt, workerStarted: false, processSettled: false });
|
|
463
469
|
this.registerExecutionOwner(job.id, task.id, durableExecutionId);
|
|
464
470
|
observability().record({
|
|
465
471
|
stage: 'transition', event: 'task.dispatch', outcome: 'succeeded',
|
package/dist/relayd.js
CHANGED
|
@@ -13,10 +13,10 @@ import { ProjectedDurableStore } from './projected-store.js';
|
|
|
13
13
|
import { discoverLocalRelayStoreSnapshots, relayStorePath, SQLiteRelayStore } from './relay-store.js';
|
|
14
14
|
import { githubAuthContext } from './github-auth.js';
|
|
15
15
|
import { dashboardJobAction, isEntrypoint, runtimePlanner, runtimeReviewer, startService, SERVICE_DEFAULTS } from './cli.js';
|
|
16
|
-
import { eventBusResolutionMessage, publishOrchestratorTaskCompletion, resolveEventBus } from './cli-support.js';
|
|
16
|
+
import { eventBusResolutionMessage, publishOrchestratorTaskStarted, publishOrchestratorTaskCompletion, resolveEventBus } from './cli-support.js';
|
|
17
17
|
import { eventFor } from './events.js';
|
|
18
18
|
import { applyJobActivateAction } from './job-actions.js';
|
|
19
|
-
import { applyOrchestratorCompleteAction } from './task-actions.js';
|
|
19
|
+
import { applyOrchestratorStartAction, applyOrchestratorCompleteAction } from './task-actions.js';
|
|
20
20
|
import { dashboardJobSetState, dashboardTaskAction, dashboardTaskSetState } from './dashboard-actions.js';
|
|
21
21
|
import { codexAndZaiUsageRegistry } from './usage.js';
|
|
22
22
|
import { defaultWorkspaceRoot, discoverWorkspaceRepositories, resolveManagedProjectPath, resolveRepositoryProjectPath } from './workspace.js';
|
|
@@ -308,6 +308,21 @@ export async function runDaemon(argv) {
|
|
|
308
308
|
await pool.reconcileTarget(overview.job.repository, overview.job.prNumber, jobId, 'control.task.create');
|
|
309
309
|
return await store.load(jobId);
|
|
310
310
|
};
|
|
311
|
+
const workspaceTaskStart = async (jobId, taskId) => {
|
|
312
|
+
const overview = cachedOverviews.find(item => item.job.id === jobId);
|
|
313
|
+
if (!overview)
|
|
314
|
+
throw new Error(`Managed job ${jobId} not found`);
|
|
315
|
+
const store = runtimeStore(overview.job.repository, overview.job.prNumber);
|
|
316
|
+
const job = await store.load(jobId);
|
|
317
|
+
const task = job.tasks.find(item => item.id === taskId);
|
|
318
|
+
if (!task)
|
|
319
|
+
throw new Error(`Task ${taskId} not found`);
|
|
320
|
+
await applyOrchestratorStartAction(task);
|
|
321
|
+
await store.saveTask(task);
|
|
322
|
+
await publishOrchestratorTaskStarted(bus, job, task);
|
|
323
|
+
await pool.reconcileTarget(overview.job.repository, overview.job.prNumber, jobId, 'control.task.start');
|
|
324
|
+
return await store.load(jobId);
|
|
325
|
+
};
|
|
311
326
|
const workspaceTaskComplete = async (jobId, taskId, summary) => {
|
|
312
327
|
const overview = cachedOverviews.find(item => item.job.id === jobId);
|
|
313
328
|
if (!overview)
|
|
@@ -388,6 +403,7 @@ export async function runDaemon(argv) {
|
|
|
388
403
|
cancel: workspaceJobAction('cancel'),
|
|
389
404
|
jobSetState: workspaceJobSetState,
|
|
390
405
|
taskCreate: workspaceTaskCreate,
|
|
406
|
+
taskStart: workspaceTaskStart,
|
|
391
407
|
taskRetry: workspaceTaskAction('retry'),
|
|
392
408
|
taskCancel: workspaceTaskAction('cancel'),
|
|
393
409
|
taskComplete: workspaceTaskComplete,
|
package/dist/task-actions.js
CHANGED
|
@@ -5,6 +5,7 @@ export const TASK_UNBLOCK_RETRY_ACTION = 'task.unblock.retry';
|
|
|
5
5
|
export const TASK_CANCEL_ACTION = 'task.cancel';
|
|
6
6
|
export const TASK_RELEASE_ACTION = 'task.release';
|
|
7
7
|
export const TASK_HOLD_ACTION = 'task.hold';
|
|
8
|
+
export const TASK_START_ACTION = 'task.start';
|
|
8
9
|
export const TASK_COMPLETE_ACTION = 'task.complete';
|
|
9
10
|
export const TASK_QUEUE_ACTION = 'task.queue';
|
|
10
11
|
function lifecycleRejection(task, action) {
|
|
@@ -79,12 +80,29 @@ export async function applyTaskQueueAction(job, task) {
|
|
|
79
80
|
clearExecution(task);
|
|
80
81
|
return task;
|
|
81
82
|
}
|
|
83
|
+
export async function applyOrchestratorStartAction(task) {
|
|
84
|
+
if (task.adapter !== 'orchestrator')
|
|
85
|
+
throw new Error('task start is only valid for adapter=orchestrator self-tasks');
|
|
86
|
+
const originalState = task.state;
|
|
87
|
+
try {
|
|
88
|
+
await transitionTaskEvent(task, 'task.started');
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
if (error instanceof Error && error.message.startsWith('No enabled workflow transition')) {
|
|
92
|
+
throw new Error(`Task ${task.id} is ${originalState}; only QUEUED orchestrator tasks can be started`);
|
|
93
|
+
}
|
|
94
|
+
throw error;
|
|
95
|
+
}
|
|
96
|
+
task.attempt = Math.max(task.attempt, 1);
|
|
97
|
+
task.updatedAt = new Date().toISOString();
|
|
98
|
+
return task;
|
|
99
|
+
}
|
|
82
100
|
export async function applyOrchestratorCompleteAction(task, summary) {
|
|
83
101
|
if (task.adapter !== 'orchestrator')
|
|
84
102
|
throw new Error('task complete is only valid for adapter=orchestrator self-tasks');
|
|
85
103
|
const originalState = task.state;
|
|
86
|
-
|
|
87
|
-
|
|
104
|
+
if (task.state !== 'RUNNING')
|
|
105
|
+
throw new Error(`Task ${task.id} is ${originalState}; orchestrator task must be started before completion`);
|
|
88
106
|
task.result = { summary };
|
|
89
107
|
clearExecution(task);
|
|
90
108
|
try {
|
|
@@ -92,7 +110,7 @@ export async function applyOrchestratorCompleteAction(task, summary) {
|
|
|
92
110
|
}
|
|
93
111
|
catch (error) {
|
|
94
112
|
if (error instanceof Error && error.message.startsWith('No enabled workflow transition')) {
|
|
95
|
-
throw new Error(`Task ${task.id} is ${originalState}; only
|
|
113
|
+
throw new Error(`Task ${task.id} is ${originalState}; only RUNNING orchestrator tasks can be completed`);
|
|
96
114
|
}
|
|
97
115
|
throw error;
|
|
98
116
|
}
|
|
@@ -127,6 +145,8 @@ export async function dispatchTaskAction(store, request) {
|
|
|
127
145
|
await applyTaskReleaseAction(task);
|
|
128
146
|
else if (request.id === TASK_HOLD_ACTION)
|
|
129
147
|
await applyTaskHoldAction(task);
|
|
148
|
+
else if (request.id === TASK_START_ACTION)
|
|
149
|
+
await applyOrchestratorStartAction(task);
|
|
130
150
|
else if (request.id === TASK_QUEUE_ACTION)
|
|
131
151
|
await applyTaskQueueAction(job, task);
|
|
132
152
|
else
|
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
|
@@ -32,6 +32,15 @@ If an intermediate result does not need that independent lifecycle, keep it insi
|
|
|
32
32
|
|
|
33
33
|
Keep sibling Tasks at a comparable abstraction level. Do not mix a whole product milestone with tiny implementation steps as peers.
|
|
34
34
|
|
|
35
|
+
### Architecture Lens
|
|
36
|
+
|
|
37
|
+
When the resolved project context exposes a canonical design-principles
|
|
38
|
+
document or architecture contract, identify the principles materially relevant
|
|
39
|
+
to each architecture-sensitive Task. Put only the resulting task-specific
|
|
40
|
+
questions, constraints, or already-decided answers in that Task's input. Do not
|
|
41
|
+
copy the full project principles into every Task. For Agents Relay itself, use
|
|
42
|
+
DESIGN-PRINCIPLES.md and its Architecture Lens.
|
|
43
|
+
|
|
35
44
|
The graph is the explanation for the decomposition: every emitted Task should map to a meaningful boundary in the graph, and important graph boundaries should not be omitted accidentally.
|
|
36
45
|
|
|
37
46
|
### Semantic ownership vs execution flow
|
|
@@ -125,12 +125,34 @@ 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.
|
|
131
145
|
|
|
132
146
|
This does not weaken immutable review semantics: final review and specialist review Tasks should continue to declare and inspect one exact immutable target/version.
|
|
133
147
|
|
|
148
|
+
When project context exposes a canonical design-principles or architecture
|
|
149
|
+
contract, Final Review must also verify the reviewed immutable target against
|
|
150
|
+
the principles that materially apply to the Job objective and changed area.
|
|
151
|
+
The reviewer should consume that project context rather than duplicating the
|
|
152
|
+
full architecture contract in the review Task. A second authority path or
|
|
153
|
+
stateful action is a blocking architecture finding when the project contract
|
|
154
|
+
forbids it.
|
|
155
|
+
|
|
134
156
|
## Dependency vs block and authoritative unblock
|
|
135
157
|
|
|
136
158
|
`dependencies` describe normal DAG ordering. Dependency completion does not create an unblock lifecycle event or Planner unblock trigger; Relay simply releases dependent work when its prerequisites succeed. `BLOCKED` is different: it records that execution reached a runtime/external condition that invalidates normal progress.
|