agents-relay 2.0.53 → 2.0.54

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -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 QUEUED or RUNNING. This command is for the same orchestrator turn that created the self-task; it is not a deferred handoff mechanism.`,
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.`,
@@ -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
- const events = [
169
- 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
- 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' }),
171
- ];
172
- for (const event of events) {
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, } from './cli-support.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 } 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, withExecutionBoundary } from './reconciler-runtime.js';
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 routeExecutableTasks(job) {
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
- for (const candidate of job.tasks.filter(item => item.kind !== 'planner' && item.adapter !== 'orchestrator' && ['QUEUED', 'BLOCKED'].includes(item.state) && item.output && !item.workerSelection && (item.workerRequirements || item.planningSource === 'model-planner'))) {
168
- const claimKey = `${job.id}/${candidate.id}`;
169
- if (taskRouteClaims.has(claimKey))
170
- continue;
171
- taskRouteClaims.add(claimKey);
172
- try {
173
- await withExecutionBoundary(job.id, candidate.id, async () => {
174
- const freshJob = await this.store.load(job.id);
175
- const task = freshJob.tasks.find(item => item.id === candidate.id);
176
- if (!task || !['QUEUED', 'BLOCKED'].includes(task.state) || task.workerSelection || !task.output)
177
- return;
178
- const requirements = task.workerRequirements ?? { capabilities: [] };
179
- const taskFacts = {
180
- job: {
181
- id: freshJob.id, title: freshJob.title, objective: freshJob.objective ?? null,
182
- description: freshJob.description ?? null, priority: freshJob.priority ?? null,
183
- executionMode: freshJob.executionMode, repository: freshJob.repository,
184
- prNumber: freshJob.prNumber, state: freshJob.state,
185
- },
186
- task: {
187
- id: task.id, kind: task.kind ?? 'work', storySize: task.storySize ?? 'M',
188
- priority: task.priority ?? null, agentName: task.agentName ?? null,
189
- parentTaskId: task.parentTaskId, dependencies: task.dependencies,
190
- capabilities: task.capabilities, input: task.input,
191
- attachments: task.attachments ?? [], output: task.output ?? null,
192
- state: task.state, attempt: task.attempt, maxAttempts: task.maxAttempts,
193
- },
194
- storySize: task.storySize ?? 'M',
195
- };
196
- const routingConstraints = task.routing ? { provider: task.routing.provider, model: task.routing.model, profile: task.routing.profile, reasoning: task.routing.reasoning } : undefined;
197
- const parent = task.agentName === 'troubleshooter' && task.parentTaskId ? freshJob.tasks.find(item => item.id === task.parentTaskId) : undefined;
198
- const parentRouteFailedBeforeStart = Boolean(parent?.routing && /exited before publishing task\.started|worker_exit_before_start|launch failed/i.test(parent.error ?? parent.failureReason ?? ''));
199
- const excludedRoutes = parentRouteFailedBeforeStart && parent?.routing
200
- ? [{ provider: parent.routing.provider, model: parent.routing.model, profile: parent.routing.profile }]
201
- : [];
202
- const routed = await routeExecution(candidates, requirements, {
203
- decisionEngine: this.options.decisionEngine,
204
- decisionMode: this.options.decisionMode,
205
- taskFacts: { ...taskFacts, recovery_parent_failed_route: excludedRoutes[0] ?? null },
206
- usage,
207
- registered,
208
- chatgptRunning: runtimeSummary?.chatgptRunning ?? 0,
209
- routingConstraints,
210
- excludedRoutes,
211
- auditStore: this.options.decisionAuditStore,
212
- auditTask: task,
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);
@@ -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
- const freshTask = freshJob.tasks.find(item => item.id === task.id);
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,6 +402,11 @@ 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 {
@@ -459,7 +461,7 @@ export class Reconciler extends ReconcilerReviewBase {
459
461
  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
462
  if (startAckSetup)
461
463
  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: Date.now(), workerStarted: false, processSettled: false });
464
+ this.live.set(task.id, { jobId: job.id, taskId: task.id, executionId: durableExecutionId, execution, controller, launchBarrier: startedAt, workerStarted: false, processSettled: false });
463
465
  this.registerExecutionOwner(job.id, task.id, durableExecutionId);
464
466
  observability().record({
465
467
  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,
@@ -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
- tryTransitionTaskEvent(task, 'task.started');
87
- task.attempt = Math.max(task.attempt, 1);
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 QUEUED or RUNNING orchestrator tasks can be completed`);
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-relay",
3
- "version": "2.0.53",
3
+ "version": "2.0.54",
4
4
  "description": "Durable async agent jobs coordinated through GitHub pull requests",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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
@@ -131,6 +131,14 @@ When success depends on the actual current UI, browser state, store listing, run
131
131
 
132
132
  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
133
 
134
+ When project context exposes a canonical design-principles or architecture
135
+ contract, Final Review must also verify the reviewed immutable target against
136
+ the principles that materially apply to the Job objective and changed area.
137
+ The reviewer should consume that project context rather than duplicating the
138
+ full architecture contract in the review Task. A second authority path or
139
+ stateful action is a blocking architecture finding when the project contract
140
+ forbids it.
141
+
134
142
  ## Dependency vs block and authoritative unblock
135
143
 
136
144
  `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.