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.
- package/README.md +32 -1
- package/dist/adapters.js +39 -3
- package/dist/blockers.js +17 -4
- package/dist/cli-blocker.js +12 -28
- package/dist/cli-help.js +11 -8
- package/dist/cli-job-create.js +17 -9
- package/dist/cli-local-merge-config.js +4 -2
- package/dist/cli-projection.js +23 -55
- package/dist/cli-review.js +7 -24
- package/dist/cli-support.js +57 -1
- package/dist/cli-worker-task.js +63 -0
- package/dist/cli.js +177 -125
- package/dist/dashboard-html.js +15 -12
- package/dist/dashboard.js +26 -1
- package/dist/e2e-injection.js +76 -0
- package/dist/github-binding-order.js +15 -0
- package/dist/github-client.js +7 -2
- package/dist/github-pr-archive.js +22 -0
- package/dist/github-webhook.js +31 -6
- package/dist/job-actions.js +29 -0
- package/dist/local-authority-store.js +10 -1
- package/dist/local-commit-events.js +2 -0
- package/dist/local-git-merge.js +17 -6
- package/dist/local-job-registry.js +10 -4
- package/dist/local-merge-delivery.js +3 -2
- package/dist/local-merge-projection.js +60 -0
- package/dist/managed-worker-input.js +13 -6
- package/dist/manual-plan.js +29 -7
- package/dist/planner-runtime.js +182 -41
- package/dist/planner.js +35 -2
- package/dist/pool.js +22 -6
- package/dist/reconciler-cancellation.js +21 -0
- package/dist/reconciler-review.js +74 -18
- package/dist/reconciler-runtime.js +69 -34
- package/dist/reconciler.js +92 -26
- package/dist/relayd-control.js +184 -0
- package/dist/relayd-dashboard-actions.js +108 -0
- package/dist/relayd-utilities.js +107 -0
- package/dist/relayd.js +216 -206
- package/dist/review-generation-fence.js +12 -0
- package/dist/runtime-status-events.js +21 -0
- package/dist/runtime-status.js +13 -0
- package/dist/task-actions.js +59 -1
- package/dist/troubleshooter.js +21 -2
- package/dist/unblock.js +47 -15
- package/dist/worker-rejection.js +11 -0
- package/dist/workflow-effect-failures.js +30 -0
- package/dist/workspace.js +95 -6
- package/package.json +4 -3
- package/skills/agents-relay/agents/planner.agent.md +3 -0
- package/skills/agents-relay/contracts/orchestrator-workflows.md +4 -7
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ? (
|
|
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
|
-
|
|
327
|
-
|
|
328
|
-
//
|
|
329
|
-
//
|
|
330
|
-
if (failureReason
|
|
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
|
-
|
|
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);
|
package/dist/reconciler.js
CHANGED
|
@@ -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 {
|
|
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
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
+
}
|