agents-relay 2.0.68 → 2.0.69

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.
Files changed (52) hide show
  1. package/README.md +32 -1
  2. package/dist/adapters.js +39 -3
  3. package/dist/blockers.js +17 -4
  4. package/dist/cli-blocker.js +12 -28
  5. package/dist/cli-help.js +11 -8
  6. package/dist/cli-job-create.js +17 -9
  7. package/dist/cli-local-merge-config.js +4 -2
  8. package/dist/cli-projection.js +23 -55
  9. package/dist/cli-review.js +7 -24
  10. package/dist/cli-support.js +57 -1
  11. package/dist/cli-worker-task.js +63 -0
  12. package/dist/cli.js +177 -125
  13. package/dist/dashboard-html.js +15 -12
  14. package/dist/dashboard.js +26 -1
  15. package/dist/e2e-injection.js +76 -0
  16. package/dist/github-binding-order.js +15 -0
  17. package/dist/github-client.js +7 -2
  18. package/dist/github-pr-archive.js +22 -0
  19. package/dist/github-webhook.js +31 -6
  20. package/dist/job-actions.js +29 -0
  21. package/dist/local-authority-store.js +10 -1
  22. package/dist/local-commit-events.js +2 -0
  23. package/dist/local-git-merge.js +17 -6
  24. package/dist/local-job-registry.js +10 -4
  25. package/dist/local-merge-delivery.js +3 -2
  26. package/dist/local-merge-projection.js +60 -0
  27. package/dist/managed-worker-input.js +13 -6
  28. package/dist/manual-plan.js +29 -7
  29. package/dist/planner-runtime.js +182 -41
  30. package/dist/planner.js +35 -2
  31. package/dist/pool.js +22 -6
  32. package/dist/reconciler-cancellation.js +21 -0
  33. package/dist/reconciler-review.js +74 -18
  34. package/dist/reconciler-runtime.js +69 -34
  35. package/dist/reconciler.js +92 -26
  36. package/dist/relayd-control.js +184 -0
  37. package/dist/relayd-dashboard-actions.js +108 -0
  38. package/dist/relayd-utilities.js +107 -0
  39. package/dist/relayd.js +216 -206
  40. package/dist/review-generation-fence.js +12 -0
  41. package/dist/runtime-status-events.js +21 -0
  42. package/dist/runtime-status.js +13 -0
  43. package/dist/task-actions.js +59 -1
  44. package/dist/troubleshooter.js +21 -2
  45. package/dist/unblock.js +47 -15
  46. package/dist/worker-rejection.js +11 -0
  47. package/dist/workflow-effect-failures.js +30 -0
  48. package/dist/workspace.js +95 -6
  49. package/package.json +4 -3
  50. package/skills/agents-relay/agents/planner.agent.md +3 -0
  51. package/skills/agents-relay/contracts/orchestrator-workflows.md +4 -7
  52. package/workflow/review-lifecycle.mmd +3 -0
@@ -1,9 +1,9 @@
1
+ import { applyJobEffectOutcomes, applyTaskEffectOutcomes } from './workflow-effect-failures.js';
2
+ import { classifyWorkerRejection, reroutableWorkerRejections } from './worker-rejection.js';
1
3
  import { applyTaskRetryAction } from './task-actions.js';
2
- import { transitionJobEvent, transitionTaskEvent } from './scheduler.js';
3
4
  import { DurableTaskTransitionRuntime } from './workflow/durable-transition.js';
4
5
  import { eventFor } from './events.js';
5
6
  import { WorkflowEffectExecutor } from './workflow/effects.js';
6
- import { workflowEffectFailureEvent } from './workflow/contracts.js';
7
7
  import { ReconcileCoordinator } from './reconcile-coordinator.js';
8
8
  import { acceptedReportedTag, eventDataWithoutTag } from './task-tag.js';
9
9
  import { rearmTerminalEventDeadline } from './terminal-event-watchdog.js';
@@ -77,31 +77,10 @@ export class ReconcilerRuntimeBase {
77
77
  });
78
78
  }
79
79
  async applyJobEffectOutcomes(job, outcomes) {
80
- for (const outcome of outcomes.filter(item => item.status === 'failed')) {
81
- const eventName = workflowEffectFailureEvent(outcome.effect);
82
- try {
83
- await transitionJobEvent(job, eventName);
84
- await this.store.saveJob(job);
85
- }
86
- catch {
87
- // Some effects are observational for a given state; failure is still emitted below.
88
- }
89
- await this.emit(eventFor(job.id, 'job', null, 'workflow.effect.failed', 'failed', `${outcome.effect} failed: ${outcome.error ?? 'unknown error'}`, 'orchestrator', { effect: outcome.effect, idempotencyKey: outcome.key, workflowEvent: eventName }, { agent: 'relay', component: 'agents-relay' }));
90
- }
80
+ await applyJobEffectOutcomes(this.store, event => this.emit(event), job, outcomes);
91
81
  }
92
82
  async applyTaskEffectOutcomes(job, task, outcomes) {
93
- for (const outcome of outcomes.filter(item => item.status === 'failed')) {
94
- const eventName = workflowEffectFailureEvent(outcome.effect);
95
- try {
96
- await transitionTaskEvent(task, eventName);
97
- task.error = outcome.error ?? `${outcome.effect} failed`;
98
- await this.store.saveTask(task);
99
- }
100
- catch {
101
- // The effect failure remains observable even if this state has no feedback transition.
102
- }
103
- await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'workflow.effect.failed', 'failed', `${outcome.effect} failed: ${outcome.error ?? 'unknown error'}`, 'orchestrator', { effect: outcome.effect, idempotencyKey: outcome.key, workflowEvent: eventName }, { agent: 'relay', component: 'agents-relay' }));
104
- }
83
+ await applyTaskEffectOutcomes(this.store, event => this.emit(event), job, task, outcomes);
105
84
  }
106
85
  async applyEvent(event) {
107
86
  const sourceId = eventSourceId(event);
@@ -256,6 +235,28 @@ export class ReconcilerRuntimeBase {
256
235
  isCorrelatedWorkerEvent(task, event) {
257
236
  return task.executionId !== null && eventSourceId(event) === executionEventSource(event.job_id, task.id, task.executionId);
258
237
  }
238
+ finalizeReviewExecution(jobId, task, event, afterCommit = true) {
239
+ if (!task.executionId)
240
+ return true;
241
+ const sourceId = eventSourceId(event);
242
+ const expected = executionEventSource(jobId, task.id, task.executionId);
243
+ if ((task.adapter === 'codex' || task.adapter === 'chatgpt') && sourceId !== expected)
244
+ return false;
245
+ if (afterCommit)
246
+ claimTerminalExecution(expected);
247
+ const executionId = task.executionId;
248
+ if (afterCommit)
249
+ this.clearExecution(task.id, executionId);
250
+ task.leaseOwner = null;
251
+ task.leaseExpiresAt = null;
252
+ task.executionId = null;
253
+ task.updatedAt = new Date().toISOString();
254
+ if (afterCommit) {
255
+ this.terminalWaiters.get(task.id)?.resolve();
256
+ this.terminalWaiters.delete(task.id);
257
+ }
258
+ return true;
259
+ }
259
260
  async failLaunch(jobId, taskId, executionId, message, phase = 'launch') {
260
261
  const claim = executionId ?? `${jobId}:${taskId}`;
261
262
  if (this.launchFailureClaims.has(claim))
@@ -282,6 +283,11 @@ export class ReconcilerRuntimeBase {
282
283
  }
283
284
  const event = eventFor(jobId, taskId, transition.task.parentTaskId, 'task.failed', 'failed', transition.task.error ?? `Worker ${phase} failed: ${message}`, 'user', { phase, reason: transition.task.failureReason, workflowEvent }, { agent: 'relay', component: 'agents-relay', watchdog: true });
284
285
  this.detach(this.emit(event), jobId, taskId, 'worker failure event');
286
+ // failLaunch already committed the authoritative FAILED state. The emitted
287
+ // task.failed observation may be ignored as a duplicate, so explicitly wake
288
+ // graph reconciliation from the durable failure fact. This is required for
289
+ // automatic Troubleshooter recovery, including reviewer start-ack failures.
290
+ this.detach(this.eventCoordinator.accept({ jobId, reason: `worker-${phase}-failure` }), jobId, taskId, 'worker failure reconcile wake');
285
291
  }
286
292
  async runtimeSettled(jobId, taskId, executionId, result, error) {
287
293
  const live = this.live.get(taskId);
@@ -291,7 +297,15 @@ export class ReconcilerRuntimeBase {
291
297
  }
292
298
  const job = await this.store.load(jobId);
293
299
  const task = job.tasks.find(item => item.id === taskId);
294
- if (terminalExecutionClaimed(jobId, taskId, executionId) || !task || task.executionId !== executionId)
300
+ // Async transport settlement is an independent, non-authoritative observation.
301
+ // A worker terminal event may already have claimed the Task; do not suppress
302
+ // transport-exit evidence merely because business completion won the race.
303
+ if (!task)
304
+ return;
305
+ const correlated = task.executionId === executionId || task.executionHistory?.some(item => item.id === executionId);
306
+ if (!correlated)
307
+ return;
308
+ if (task.adapter !== 'chatgpt' && (task.executionId !== executionId || terminalExecutionClaimed(jobId, taskId, executionId)))
295
309
  return;
296
310
  if (task.adapter !== 'chatgpt' && task.executionId === executionId && live?.executionId === executionId)
297
311
  rearmTerminalEventDeadline(this.terminalTimers, taskId, task.timeoutMs - (Date.now() - live.launchBarrier), this.options.postExitTerminalGraceMs ?? 15_000, () => this.detach(this.timeoutTask(jobId, taskId, executionId), jobId, taskId, 'post-exit terminal event timeout'));
@@ -307,7 +321,7 @@ export class ReconcilerRuntimeBase {
307
321
  const exit = live?.executionId === executionId && live.processSettled ? live.execution.processExit : undefined;
308
322
  const rawError = live?.executionId === executionId && live.processSettled ? (live.processError || exit?.stderr || '').trim() : '';
309
323
  const failedExit = Boolean(exit && (exit.exitCode !== 0 || exit.signal));
310
- const failureReason = failedExit ? (/rate.limit.exceeded|usage limit reached/i.test(rawError) ? 'rate_limit_exceeded' : 'worker_process_failed') : 'terminal_event_timeout';
324
+ const failureReason = failedExit ? classifyWorkerRejection(rawError) : 'terminal_event_timeout';
311
325
  const failureMessage = failedExit ? (rawError || `Worker exited with code ${exit?.exitCode ?? 'null'}${exit?.signal ? `, signal ${exit.signal}` : ''}`) : `Task ${taskId} timed out waiting for the mandatory terminal worker event`;
312
326
  const transition = await this.taskTransitions.apply(jobId, taskId, 'watchdog.failed', {
313
327
  eligible: (_job, task) => task.executionId === executionId,
@@ -323,21 +337,38 @@ export class ReconcilerRuntimeBase {
323
337
  this.clearExecution(taskId, executionId);
324
338
  return;
325
339
  }
326
- const event = eventFor(jobId, taskId, transition.task.parentTaskId, 'task.failed', 'failed', transition.task.error ?? failureMessage, 'user', { reason: failureReason, exitCode: exit?.exitCode ?? null, signal: exit?.signal ?? null, timeoutMs: transition.task.timeoutMs, workflowEvent: 'watchdog.failed', executionId }, { agent: 'relay', component: 'agents-relay', watchdog: true });
327
- await this.emit(event);
328
- // Provider quota failures are recoverable. Drop the stale selection so the
329
- // next launch runs the normal worker/model router with fresh usage data.
330
- if (failureReason === 'rate_limit_exceeded' && transition.task.attempt < transition.task.maxAttempts) {
340
+ let reroutedTask = null;
341
+ // Provider quota failures are recoverable. Commit the retry + route exclusion
342
+ // before publishing task.failed so event-driven reconciliation can never see
343
+ // an intermediate FAILED state and spawn a Troubleshooter for quota exhaustion.
344
+ if (reroutableWorkerRejections.has(failureReason) && transition.task.attempt < transition.task.maxAttempts) {
331
345
  const current = (await this.store.load(jobId)).tasks.find(task => task.id === taskId);
332
346
  if (current?.state === 'FAILED' && current.failureReason === failureReason && current.attempt === transition.task.attempt) {
347
+ const failedRoute = current.routing;
333
348
  await applyTaskRetryAction(current);
349
+ if (failedRoute) {
350
+ const duplicate = current.routingExclusions?.some(item => item.provider === failedRoute.provider && item.model === failedRoute.model && item.profile === failedRoute.profile);
351
+ if (!duplicate)
352
+ current.routingExclusions = [...(current.routingExclusions ?? []), {
353
+ provider: failedRoute.provider,
354
+ model: failedRoute.model,
355
+ ...(failedRoute.profile ? { profile: failedRoute.profile } : {}),
356
+ reason: 'rate_limit_exceeded',
357
+ at: new Date().toISOString(),
358
+ }];
359
+ }
334
360
  current.routing = undefined;
335
361
  current.workerSelection = undefined;
336
362
  await this.store.saveTask(current);
337
- await this.emit(eventFor(jobId, taskId, current.parentTaskId, 'task.rerouting', 'queued', 'Provider rate limit reached; retrying with fresh routing and usage data', 'orchestrator', { previousFailure: failureReason, attempt: current.attempt, maxAttempts: current.maxAttempts }));
338
- await this.eventCoordinator.accept({ jobId, reason: 'rate-limit-reroute' });
363
+ reroutedTask = current;
339
364
  }
340
365
  }
366
+ const event = eventFor(jobId, taskId, transition.task.parentTaskId, 'task.failed', 'failed', transition.task.error ?? failureMessage, 'user', { reason: failureReason, exitCode: exit?.exitCode ?? null, signal: exit?.signal ?? null, timeoutMs: transition.task.timeoutMs, workflowEvent: 'watchdog.failed', executionId }, { agent: 'relay', component: 'agents-relay', watchdog: true });
367
+ await this.emit(event);
368
+ if (reroutedTask) {
369
+ await this.emit(eventFor(jobId, taskId, reroutedTask.parentTaskId, 'task.rerouting', 'queued', `Worker rejection ${failureReason}; retrying with fresh routing and usage data`, 'orchestrator', { previousFailure: failureReason, attempt: reroutedTask.attempt, maxAttempts: reroutedTask.maxAttempts }));
370
+ await this.eventCoordinator.accept({ jobId, reason: 'worker-rejection-reroute' });
371
+ }
341
372
  }
342
373
  async applyTerminalEvent(event, trustedInternal = false) {
343
374
  const liveAtTerminal = this.live.get(event.task_id);
@@ -346,6 +377,10 @@ export class ReconcilerRuntimeBase {
346
377
  eligible: (_job, task) => {
347
378
  if (!['codex', 'chatgpt', 'orchestrator'].includes(task.adapter))
348
379
  return false;
380
+ // Reviewer business terminal state is owned by review.approved / review.changes_required / review.blocked.
381
+ // A generic task.completed may arrive before the review decision and must not consume the execution generation.
382
+ if (task.agentName === 'reviewer' && event.type === 'task.completed')
383
+ return false;
349
384
  const modelBacked = task.adapter === 'codex' || task.adapter === 'chatgpt';
350
385
  if (!trustedInternal && modelBacked && !this.isCorrelatedWorkerEvent(task, event)) {
351
386
  const eventExecutionId = executionIdFromSource(eventSourceId(event), _job.id, task.id);
@@ -1,12 +1,15 @@
1
+ import { cancelActiveTasks } from './reconciler-cancellation.js';
2
+ import { authorizedInjection } from './e2e-injection.js';
1
3
  import { implementationComplete } from './runtime-status.js';
2
4
  import { bindPlannedReviewHead } from './planned-review-head.js';
3
5
  import { randomUUID } from 'node:crypto';
4
6
  import { storySizeTimeoutMs, chatGptStartAckTimeoutMs } from './types.js';
5
7
  import { dependencyReady, schedule, transitionTaskEvent } from './scheduler.js';
6
- import { applyTaskCancelAction } from './task-actions.js';
8
+ import { applyTaskRetryAction } from './task-actions.js';
7
9
  import { LaunchFailure, StartAckTimeout } from './adapters.js';
8
10
  import { eventFor } from './events.js';
9
11
  import { createPlanRound, materializePlannerChild, pendingPlan } from './planner.js';
12
+ import { retryPendingPlannerUnblocks } from './planner-runtime.js';
10
13
  import { dispatchPlannerOrchestration, plannerOrchestrationFacts } from './workflow/planner-orchestration-v2.js';
11
14
  import { resolveExecutionContext } from './execution-context.js';
12
15
  import { ReconcilerReviewBase } from './reconciler-review.js';
@@ -15,7 +18,7 @@ import { executeManualPlan } from './manual-plan.js';
15
18
  import { executionEventSource, withExecutionBoundary } from './reconciler-runtime.js';
16
19
  export { executionEventSource } from './reconciler-runtime.js';
17
20
  import { CallbackWorkflowEffectHandler } from './workflow/effects.js';
18
- import { pendingTroubleshooterTasks } from './troubleshooter.js';
21
+ import { exhaustedTroubleshooterRecoveries, pendingTroubleshooterTasks } from './troubleshooter.js';
19
22
  import { taskUsesCanonicalProjectRun } from './workspace.js';
20
23
  import { cleanupMergedJobWorktree } from './job-worktree-cleanup.js';
21
24
  import { jobHasActiveExecution } from './job-execution-slot.js';
@@ -98,10 +101,49 @@ export class Reconciler extends ReconcilerReviewBase {
98
101
  await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'task.orchestrator_rescued', 'queued', `Stale orchestrator task ${task.id} converted to ChatGPT execution`, 'orchestrator', { ageMs: rescue.ageMs, adapter: 'chatgpt' }, { agent: 'relay', component: 'agents-relay' }));
99
102
  }
100
103
  await this.recoverPlannerChildren(job);
104
+ await retryPendingPlannerUnblocks({
105
+ store: this.store,
106
+ options: this.options,
107
+ live: this.plannerLive,
108
+ plannerContext: (currentJob, currentPlan) => this.plannerContext(currentJob, currentPlan),
109
+ emit: event => this.emit(event),
110
+ detach: (promise, currentJobId, planId, context) => this.detach(promise, currentJobId, planId, context),
111
+ reconcile: currentJobId => this.reconcile(currentJobId),
112
+ }, jobId);
101
113
  for (const recovery of pendingTroubleshooterTasks(await this.store.load(jobId))) {
102
114
  await this.store.appendTask(recovery);
103
115
  await this.emit(eventFor(job.id, recovery.id, null, "troubleshooter.created", "queued", "Troubleshooter task " + recovery.id + " created for failed/blocked work", "orchestrator", { agentName: "troubleshooter" }));
104
116
  }
117
+ {
118
+ const recoveryJob = await this.store.load(jobId);
119
+ // A failed recovery worker is itself recoverable: retry the SAME Task
120
+ // within its budget. No recursive Troubleshooter is generated.
121
+ for (const recovery of recoveryJob.tasks) {
122
+ if (recovery.agentName !== 'troubleshooter' || recovery.state !== 'FAILED' || recovery.attempt >= recovery.maxAttempts)
123
+ continue;
124
+ const original = recoveryJob.tasks.find(task => task.id === recovery.parentTaskId);
125
+ if (!original || !['FAILED', 'BLOCKED'].includes(original.state))
126
+ continue;
127
+ await applyTaskRetryAction(recovery);
128
+ recovery.routing = undefined;
129
+ recovery.workerSelection = undefined;
130
+ await this.store.saveTask(recovery);
131
+ await this.emit(eventFor(job.id, recovery.id, original.id, 'troubleshooter.retry_queued', 'queued', `Retrying failed Troubleshooter ${recovery.id} (attempt ${recovery.attempt + 1}/${recovery.maxAttempts})`, 'orchestrator', { originalTaskId: original.id, attempt: recovery.attempt + 1, maxAttempts: recovery.maxAttempts }));
132
+ }
133
+ const exhaustedJob = await this.store.load(jobId);
134
+ for (const exhausted of exhaustedTroubleshooterRecoveries(exhaustedJob)) {
135
+ const at = new Date().toISOString();
136
+ exhaustedJob.recoveryFailures = [...(exhaustedJob.recoveryFailures ?? []), {
137
+ taskId: exhausted.original.id,
138
+ recoveryTaskId: exhausted.recovery.id,
139
+ reason: exhausted.reason,
140
+ at,
141
+ }];
142
+ exhaustedJob.updatedAt = at;
143
+ await this.store.saveJob(exhaustedJob);
144
+ await this.emit(eventFor(job.id, exhausted.recovery.id, exhausted.original.id, 'recovery.exhausted', 'failed', `Recovery exhausted for ${exhausted.original.id}: ${exhausted.reason}`, 'user', { originalTaskId: exhausted.original.id, recoveryTaskId: exhausted.recovery.id, reason: exhausted.reason }, { agent: 'relay', component: 'agents-relay' }));
145
+ }
146
+ }
105
147
  for (const task of job.tasks)
106
148
  if (task.state === 'CANCELLED') {
107
149
  this.cancelExecutionGeneration(job.id, task.id, task.executionId ?? task.executionHistory?.at(-1)?.id);
@@ -133,7 +175,7 @@ export class Reconciler extends ReconcilerReviewBase {
133
175
  await this.ensureFinalReview(finalState, headSha);
134
176
  await this.dispatchExecutableTasks(jobId);
135
177
  finalState = await this.store.load(jobId);
136
- if (await reconcileLocalGitCompletion(this.store, finalState, headSha, true, this.options.projectPathForJob))
178
+ if (await reconcileLocalGitCompletion(this.store, finalState, headSha, this.options.noGitHub === true, this.options.projectPathForJob))
137
179
  return this.store.load(jobId);
138
180
  await dispatchJobReconcile(finalState, { jobActive: finalState.state === 'ACTIVE', implementationComplete: implementationComplete(finalState), headAvailable: Boolean(headSha), deliveryReady: finalState.localMerge?.status === 'succeeded' }, async (actionId) => {
139
181
  const outcomes = await this.workflowEffects.execute([actionId], { jobId: finalState.id, transitionKey: `reconcile:${headSha ?? 'no-head'}`, data: { headSha } });
@@ -172,29 +214,11 @@ export class Reconciler extends ReconcilerReviewBase {
172
214
  }
173
215
  }
174
216
  async cancelActiveTasks(jobId, reason) {
175
- const job = await this.store.load(jobId);
176
- for (const plan of (job.plans ?? []).filter(item => item.status === 'PLANNING')) {
177
- const live = this.plannerLive.get(plan.id);
178
- if (live) {
179
- clearTimeout(live.timer);
180
- live.controller.abort();
181
- this.plannerLive.delete(plan.id);
182
- }
183
- plan.status = 'FAILED';
184
- plan.error = reason;
185
- plan.assessment = `Planning failed: ${reason}`;
186
- plan.leaseOwner = null;
187
- plan.leaseExpiresAt = null;
188
- plan.executionId = null;
189
- plan.updatedAt = new Date().toISOString();
190
- await this.store.savePlan(plan);
191
- }
192
- for (const task of job.tasks.filter(item => item.kind !== 'planner' && !['SUCCEEDED', 'FAILED', 'UNBLOCKED', 'CANCELLED'].includes(item.state))) {
193
- this.cancelExecutionGeneration(job.id, task.id, task.executionId ?? task.executionHistory?.at(-1)?.id);
194
- this.plannerLive.get(task.id)?.controller.abort();
195
- await applyTaskCancelAction(task, reason);
196
- await this.store.saveTask(task);
197
- }
217
+ return cancelActiveTasks(this.store, jobId, reason, taskId => { const live = this.plannerLive.get(taskId); if (live) {
218
+ clearTimeout(live.timer);
219
+ live.controller.abort();
220
+ this.plannerLive.delete(taskId);
221
+ } }, (job, taskId, executionId) => { this.cancelExecutionGeneration(job, taskId, executionId); });
198
222
  }
199
223
  executionRecoveryPorts(job) {
200
224
  return {
@@ -339,6 +363,31 @@ export class Reconciler extends ReconcilerReviewBase {
339
363
  attempt: freshTask.attempt,
340
364
  startedAt: new Date(startedAt).toISOString(),
341
365
  }].slice(-8);
366
+ if (this.store.mutateJob) {
367
+ let claimed;
368
+ await this.store.mutateJob(job.id, current => {
369
+ const durable = current.tasks.find(item => item.id === freshTask.id);
370
+ if (!durable || durable.state !== 'QUEUED' || durable.executionId !== null
371
+ || !dependencyReady(durable, new Map(current.tasks.map(item => [item.id, item]))))
372
+ return current;
373
+ // Routing is a pre-claim fact; copy only that fact into the fresh
374
+ // authoritative snapshot, then claim the generation atomically.
375
+ if (freshTask.routing)
376
+ durable.routing = structuredClone(freshTask.routing);
377
+ if (freshTask.workerSelection)
378
+ durable.workerSelection = structuredClone(freshTask.workerSelection);
379
+ durable.attempt += 1;
380
+ durable.leaseOwner = this.options.owner;
381
+ durable.leaseExpiresAt = new Date(startedAt + leaseMs).toISOString();
382
+ durable.executionId = randomUUID();
383
+ durable.executionHistory = [...(durable.executionHistory ?? []), {
384
+ id: durable.executionId, attempt: durable.attempt, startedAt: new Date(startedAt).toISOString(),
385
+ }].slice(-8);
386
+ claimed = structuredClone(durable);
387
+ return current;
388
+ });
389
+ return claimed;
390
+ }
342
391
  await this.store.saveTask(freshTask);
343
392
  return freshTask;
344
393
  }) ?? null;
@@ -380,7 +429,24 @@ export class Reconciler extends ReconcilerReviewBase {
380
429
  const startAckSetup = task.adapter === 'chatgpt' ? await this.startAck(job.id, task.id, sourceId, Date.now(), this.options.startAckTimeoutMs ?? chatGptStartAckTimeoutMs(task.timeoutMs)) : null;
381
430
  const closePolicy = task.chatgptTabClosePolicy ?? this.options.chatgptTabClosePolicy ?? adapter.tabClosePolicy ?? 'after-start';
382
431
  const terminalWait = task.adapter === 'chatgpt' && closePolicy === 'after-terminal' ? this.terminalWait(task.id, durableExecutionId, startedAt - 1) : null;
432
+ // Consume E2E injection before execution: a retry will not repeat it.
433
+ // Production repositories ignore the option entirely.
434
+ const declaration = authorizedInjection(job.repository, task.testInjection);
435
+ const testInjection = declaration && ((task.adapter === 'codex' && declaration.method === 'worker.codex.launch') || (task.adapter === 'chatgpt' && declaration.method === 'browserWorkspace.command')) ? declaration : undefined;
436
+ if (testInjection) {
437
+ const durable = (await this.store.load(job.id)).tasks.find(item => item.id === task.id);
438
+ if (!durable || durable.executionId !== durableExecutionId)
439
+ return;
440
+ delete durable.testInjection;
441
+ durable.updatedAt = new Date(Math.max(Date.now(), Date.parse(durable.updatedAt) + 1)).toISOString();
442
+ await this.store.saveTask(durable);
443
+ const consumed = (await this.store.load(job.id)).tasks.find(item => item.id === task.id);
444
+ if (!consumed || consumed.executionId !== durableExecutionId || consumed.testInjection)
445
+ throw new Error(`E2E injection consumption was not durably committed for ${task.id}`);
446
+ delete task.testInjection;
447
+ }
383
448
  const launchContext = {
449
+ ...(testInjection ? { testInjection } : {}),
384
450
  ...(startAckSetup ? { waitForWorkerStart: startAckSetup.ack } : {}),
385
451
  ...(terminalWait ? { waitForTerminal: terminalWait.promise } : {}),
386
452
  executionContext,
@@ -0,0 +1,184 @@
1
+ export function createWorkspaceControl(deps) {
2
+ const { client, trusted, noGitHub, repositories, refreshAll, requireLocalJobBinding, makeReconciler, concurrency, pool, bus, isEffectivelyBlocked, unblockAutonomousTasks, unblockTask, publishTaskUnblocked, dispatchTaskAction, registerBlockerDependency, discoverAgents, ensureManagedGitHubJob } = deps;
3
+ const workspaceControl = async (body) => {
4
+ const command = typeof body.command === 'string' ? body.command : '';
5
+ const jobId = typeof body.jobId === 'string' ? body.jobId : '';
6
+ if (!command)
7
+ throw new Error('control command is required');
8
+ if (command === 'job.create') {
9
+ const repository = typeof body.repository === 'string' ? body.repository : '';
10
+ const options = (body.options && typeof body.options === 'object' ? body.options : {});
11
+ if (!repository || typeof options.id !== 'string' || !options.id)
12
+ throw new Error('job.create requires repository and id');
13
+ const result = await ensureManagedGitHubJob(client, repository, trusted, {
14
+ id: options.id,
15
+ title: typeof options.title === 'string' ? options.title : undefined,
16
+ objective: typeof options.objective === 'string' ? options.objective : undefined,
17
+ projectPath: typeof options.projectPath === 'string' ? options.projectPath : undefined,
18
+ priority: options.priority,
19
+ executionMode: options.executionMode,
20
+ schedule: options.schedule,
21
+ tag: options.tag,
22
+ reviewEnabled: typeof options.reviewEnabled === 'boolean' ? options.reviewEnabled : undefined,
23
+ continuation: options.continuation,
24
+ prNumber: typeof options.prNumber === 'number' ? options.prNumber : undefined,
25
+ head: typeof options.head === 'string' ? options.head : undefined,
26
+ base: typeof options.base === 'string' ? options.base : undefined,
27
+ body: typeof options.body === 'string' ? options.body : undefined,
28
+ reviewerThreadId: typeof options.reviewerThreadId === 'string' ? options.reviewerThreadId : undefined,
29
+ creatorThreadId: typeof options.creatorThreadId === 'string' ? options.creatorThreadId : undefined,
30
+ noGitHub,
31
+ localMergeSource: typeof options.localMergeSource === 'string' ? options.localMergeSource : undefined,
32
+ localMergeTarget: typeof options.localMergeTarget === 'string' ? options.localMergeTarget : undefined,
33
+ });
34
+ if (!repositories.includes(repository))
35
+ repositories.push(repository);
36
+ await refreshAll();
37
+ if (body.initialPlan === true) {
38
+ const binding = await requireLocalJobBinding(result.job.id);
39
+ const reconciler = makeReconciler(binding.store, concurrency);
40
+ try {
41
+ const planned = await reconciler.planJob(result.job.id);
42
+ await pool.reconcileLocalJob(result.job.id, 'control.job.create.initial-plan');
43
+ return { repository, pr: result.pr.number, job: result.job.id, executionMode: result.job.executionMode, state: result.job.state, initialPlan: planned.round.id };
44
+ }
45
+ finally {
46
+ await reconciler.close();
47
+ }
48
+ }
49
+ return { repository, pr: result.pr.number, job: result.job.id, executionMode: result.job.executionMode, state: result.job.state };
50
+ }
51
+ if (!jobId)
52
+ throw new Error(command + ' requires jobId');
53
+ const binding = await requireLocalJobBinding(jobId);
54
+ const store = binding.store;
55
+ const job = await store.load(jobId);
56
+ if (command === 'job.update') {
57
+ const patch = (body.patch && typeof body.patch === 'object' ? body.patch : {});
58
+ const mutable = ['title', 'objective', 'projectPath', 'priority', 'executionMode', 'schedule', 'tag', 'autoMerge', 'reviewEnabled', 'reviewerThreadId', 'creatorThreadId', 'continuation', 'localMerge'];
59
+ for (const key of mutable)
60
+ if (Object.prototype.hasOwnProperty.call(patch, key))
61
+ job[key] = patch[key];
62
+ job.updatedAt = new Date().toISOString();
63
+ await store.saveJob(job);
64
+ await refreshAll();
65
+ return await store.load(jobId);
66
+ }
67
+ if (command === 'job.plan') {
68
+ const reconciler = makeReconciler(store, concurrency);
69
+ try {
70
+ const planned = await reconciler.planJob(jobId, typeof body.guidance === 'string' && body.guidance.trim() ? body.guidance : undefined);
71
+ await pool.reconcileLocalJob(jobId, 'control.job.plan');
72
+ return planned.round;
73
+ }
74
+ finally {
75
+ await reconciler.close();
76
+ }
77
+ }
78
+ if (command === 'job.unblock') {
79
+ const reason = typeof body.reason === 'string' ? body.reason : '';
80
+ if (!reason.trim())
81
+ throw new Error('job unblock requires reason');
82
+ const blocked = job.tasks.filter(isEffectivelyBlocked);
83
+ if (!blocked.length)
84
+ throw new Error(`Job ${job.id} has no effective BLOCKED work task`);
85
+ if (job.executionMode === 'autonomous')
86
+ await unblockAutonomousTasks({ store, job, tasks: blocked, trigger: { source: 'cli', reason } });
87
+ else
88
+ for (const task of blocked)
89
+ await unblockTask({ store, job, task, trigger: { source: 'cli', reason } });
90
+ const refreshed = await store.load(jobId);
91
+ for (const task of blocked)
92
+ await publishTaskUnblocked(bus, refreshed, task.id, reason);
93
+ await pool.reconcileLocalJob(jobId, 'control.job.unblock');
94
+ return await store.load(jobId);
95
+ }
96
+ if (command === 'task.recover') {
97
+ const taskId = typeof body.taskId === 'string' ? body.taskId : '';
98
+ const recoveryTaskId = typeof body.recoveryTaskId === 'string' ? body.recoveryTaskId : '';
99
+ if (!taskId || !recoveryTaskId)
100
+ throw new Error('task recover requires taskId and recoveryTaskId');
101
+ await dispatchTaskAction(store, { id: 'task.recover', jobId, taskId, recoveryTaskId });
102
+ await pool.reconcileLocalJob(jobId, 'control.task.recover');
103
+ return await store.load(jobId);
104
+ }
105
+ if (command === 'task.unblock') {
106
+ const taskId = typeof body.taskId === 'string' ? body.taskId : '';
107
+ const reason = typeof body.reason === 'string' ? body.reason : '';
108
+ const task = job.tasks.find((item) => item.id === taskId);
109
+ if (!task)
110
+ throw new Error(`Task ${taskId} not found`);
111
+ await unblockTask({ store, job, task, trigger: { source: 'cli', reason } });
112
+ const refreshed = await store.load(jobId);
113
+ await publishTaskUnblocked(bus, refreshed, taskId, reason);
114
+ await pool.reconcileLocalJob(jobId, 'control.task.unblock');
115
+ return await store.load(jobId);
116
+ }
117
+ if (command === 'review.request') {
118
+ if (job.state === 'CANCELLED' || job.state === 'COMPLETED')
119
+ throw new Error('Cannot request review for terminal Job');
120
+ if (!job.reviewEnabled)
121
+ throw new Error('Review disabled for this Job');
122
+ if (typeof body.threadId === 'string') {
123
+ job.reviewerThreadId = body.threadId;
124
+ job.updatedAt = new Date().toISOString();
125
+ await store.saveJob(job);
126
+ }
127
+ await pool.reconcileLocalJob(jobId, 'control.review.request');
128
+ return await store.load(jobId);
129
+ }
130
+ if (command === 'blocker.add') {
131
+ const dependentJobId = typeof body.dependentJobId === 'string' ? body.dependentJobId : '';
132
+ const dependentTaskId = typeof body.dependentTaskId === 'string' ? body.dependentTaskId : '';
133
+ const dependent = await requireLocalJobBinding(dependentJobId);
134
+ const relation = await registerBlockerDependency({ blockerStore: store, blockerJobId: jobId, dependentStore: dependent.store, dependentJobId, dependentTaskId, resolutionMode: body.resolutionMode === 'verified' ? 'verified' : 'merge' });
135
+ await refreshAll();
136
+ return relation;
137
+ }
138
+ if (command === 'blocker.resolve') {
139
+ if (!store.resolveBlockerDependents)
140
+ throw new Error('Blocker resolution unavailable');
141
+ const summary = await store.resolveBlockerDependents(jobId, body.resolution === 'merge' ? 'merge' : 'verified');
142
+ for (const target of summary.unblocked)
143
+ await pool.reconcileLocalJob(target.jobId, 'control.blocker.resolve');
144
+ await refreshAll();
145
+ return summary;
146
+ }
147
+ if (command === 'projection.conflicts')
148
+ return await binding.relay.listProjectionConflicts();
149
+ if (command === 'job.reconcile') {
150
+ await pool.reconcileLocalJob(jobId, 'control.job.reconcile');
151
+ return await store.load(jobId);
152
+ }
153
+ if (command === 'agent.discover') {
154
+ return discoverAgents(await store.listAgents(), (body.requirements && typeof body.requirements === 'object' ? body.requirements : {}));
155
+ }
156
+ if (command === 'projection.resolve') {
157
+ const id = Number(body.projectionId);
158
+ const action = body.action === 'discard_local' ? 'discard_local' : 'requeue';
159
+ if (!Number.isInteger(id) || id <= 0)
160
+ throw new Error('projection resolve requires projectionId');
161
+ const resolved = await binding.relay.resolveProjectionConflict(id, { action, ...(typeof body.note === 'string' && body.note ? { note: body.note } : {}) });
162
+ if (!resolved)
163
+ throw new Error(`Projection ${id} is not in a resolvable conflict state`);
164
+ return { resolved: id, action };
165
+ }
166
+ if (command === 'record') {
167
+ const task = body.task;
168
+ if (!task || task.jobId !== jobId)
169
+ throw new Error('record requires a task for this Job');
170
+ await store.appendTask(task);
171
+ await pool.reconcileLocalJob(jobId, 'control.record');
172
+ return await store.load(jobId);
173
+ }
174
+ if (command === 'agent.register') {
175
+ const agent = body.agent;
176
+ if (!agent)
177
+ throw new Error('agent.register requires agent');
178
+ await store.saveAgent(agent);
179
+ return agent;
180
+ }
181
+ throw new Error('Unsupported control command ' + command);
182
+ };
183
+ return workspaceControl;
184
+ }
@@ -0,0 +1,108 @@
1
+ #!/usr/bin/env node
2
+ export function createDashboardActions(deps) {
3
+ const { cachedOverviews, requireLocalJobBinding, pool, dashboardJobAction, makeReconciler, concurrency, dashboardJobSetState, refreshAll, applyJobActivateAction, applyOrchestratorStartAction, applyOrchestratorCompleteAction, publishOrchestratorTaskStarted, publishOrchestratorTaskCompletion, bus, dashboardTaskAction, dashboardTaskSetState } = deps;
4
+ const dashboardOverview = (jobId, target) => cachedOverviews.find((item) => item.job.id === jobId
5
+ && (!target?.repository || item.job.repository === target.repository)
6
+ && (!target?.prNumber || (item.job.externalPrNumber ?? item.job.prNumber) === target.prNumber));
7
+ const workspaceJobAction = (action) => async (jobId) => {
8
+ const overview = cachedOverviews.find((item) => item.job.id === jobId);
9
+ if (!overview)
10
+ throw new Error(`Managed job ${jobId} not found`);
11
+ const store = (await requireLocalJobBinding(jobId)).store;
12
+ if (action === 'reconcile') {
13
+ await pool.reconcileLocalJob(jobId, 'control.job.reconcile');
14
+ return await store.load(jobId);
15
+ }
16
+ return await dashboardJobAction(store, makeReconciler(store, concurrency), action)(jobId);
17
+ };
18
+ const workspaceJobSetState = async (jobId, state, target) => {
19
+ const overview = dashboardOverview(jobId, target);
20
+ if (!overview)
21
+ throw new Error(`Managed job ${jobId} not found`);
22
+ const store = (await requireLocalJobBinding(jobId)).store;
23
+ const result = await dashboardJobSetState(store)(jobId, state);
24
+ await refreshAll();
25
+ return result;
26
+ };
27
+ const workspaceTaskCreate = async (jobId, task) => {
28
+ const overview = cachedOverviews.find((item) => item.job.id === jobId);
29
+ if (!overview)
30
+ throw new Error(`Managed job ${jobId} not found`);
31
+ if (task.jobId !== jobId)
32
+ throw new Error(`Task ${task.id} belongs to ${task.jobId}, not ${jobId}`);
33
+ const store = (await requireLocalJobBinding(jobId)).store;
34
+ const job = await store.load(jobId);
35
+ if (['COMPLETED', 'CANCELLED'].includes(job.state))
36
+ throw new Error(`Cannot create task on terminal job ${job.id} (${job.state})`);
37
+ if (store.mutateJob) {
38
+ await store.mutateJob(jobId, (current) => {
39
+ if (['COMPLETED', 'CANCELLED'].includes(current.state))
40
+ throw new Error(`Cannot create task on terminal job ${current.id}`);
41
+ if (current.tasks.some((item) => item.id === task.id))
42
+ throw new Error(`Task ${task.id} already exists`);
43
+ current.tasks.push(structuredClone(task));
44
+ if (current.state === 'DRAFT')
45
+ applyJobActivateAction(current);
46
+ current.updatedAt = new Date().toISOString();
47
+ return current;
48
+ });
49
+ }
50
+ else {
51
+ await store.appendTask(task);
52
+ const refreshed = await store.load(jobId);
53
+ if (refreshed.state === 'DRAFT') {
54
+ await applyJobActivateAction(refreshed);
55
+ await store.saveJob(refreshed);
56
+ }
57
+ }
58
+ await pool.reconcileLocalJob(jobId, 'control.task.create');
59
+ return await store.load(jobId);
60
+ };
61
+ const workspaceTaskStart = async (jobId, taskId) => {
62
+ const overview = cachedOverviews.find((item) => item.job.id === jobId);
63
+ if (!overview)
64
+ throw new Error(`Managed job ${jobId} not found`);
65
+ const store = (await requireLocalJobBinding(jobId)).store;
66
+ const job = await store.load(jobId);
67
+ const task = job.tasks.find((item) => item.id === taskId);
68
+ if (!task)
69
+ throw new Error(`Task ${taskId} not found`);
70
+ await applyOrchestratorStartAction(task);
71
+ await store.saveTask(task);
72
+ await publishOrchestratorTaskStarted(bus, job, task);
73
+ await pool.reconcileLocalJob(jobId, 'control.task.start');
74
+ return await store.load(jobId);
75
+ };
76
+ const workspaceTaskComplete = async (jobId, taskId, summary) => {
77
+ const overview = cachedOverviews.find((item) => item.job.id === jobId);
78
+ if (!overview)
79
+ throw new Error(`Managed job ${jobId} not found`);
80
+ const store = (await requireLocalJobBinding(jobId)).store;
81
+ const job = await store.load(jobId);
82
+ const task = job.tasks.find((item) => item.id === taskId);
83
+ if (!task)
84
+ throw new Error(`Task ${taskId} not found`);
85
+ await applyOrchestratorCompleteAction(task, summary);
86
+ await store.saveTask(task);
87
+ await publishOrchestratorTaskCompletion(bus, job, task, summary);
88
+ await pool.reconcileLocalJob(jobId, 'control.task.complete');
89
+ return await store.load(jobId);
90
+ };
91
+ const workspaceTaskAction = (action) => async (jobId, taskId) => {
92
+ const overview = cachedOverviews.find((item) => item.job.id === jobId);
93
+ if (!overview)
94
+ throw new Error(`Managed job ${jobId} not found`);
95
+ const store = (await requireLocalJobBinding(jobId)).store;
96
+ return await dashboardTaskAction(store, makeReconciler(store, concurrency), action)(jobId, taskId);
97
+ };
98
+ const workspaceTaskSetState = async (jobId, taskId, state, target) => {
99
+ const overview = dashboardOverview(jobId, target);
100
+ if (!overview)
101
+ throw new Error(`Managed job ${jobId} not found`);
102
+ const store = (await requireLocalJobBinding(jobId)).store;
103
+ const result = await dashboardTaskSetState(store)(jobId, taskId, state);
104
+ await refreshAll();
105
+ return result;
106
+ };
107
+ return { dashboardOverview, workspaceJobAction, workspaceJobSetState, workspaceTaskCreate, workspaceTaskStart, workspaceTaskComplete, workspaceTaskAction, workspaceTaskSetState };
108
+ }