agents-relay 1.0.90 → 1.0.92
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/dist/cli-help.js +192 -0
- package/dist/cli-support.js +103 -0
- package/dist/cli.js +6 -293
- package/dist/dashboard-html.js +292 -0
- package/dist/dashboard.js +2 -292
- package/dist/reconciler-review.js +158 -0
- package/dist/reconciler-runtime.js +404 -0
- package/dist/reconciler.js +66 -535
- package/dist/scheduler.js +11 -33
- package/dist/store-github.js +217 -0
- package/dist/store-task-definition.js +19 -0
- package/dist/store.js +3 -235
- package/dist/workflow/contracts.js +84 -0
- package/dist/workflow/definitions.js +19 -5
- package/dist/workflow/effects.js +74 -0
- package/dist/workflow/mermaid.js +6 -2
- package/dist/workflow/rollout.js +70 -56
- package/dist/workflow/runtime.js +39 -0
- package/dist/workflow/task-reconcile.js +48 -0
- package/package.json +1 -1
- package/workflow/job-lifecycle.mmd +10 -5
- package/workflow/task-lifecycle.mmd +12 -10
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
import { transitionJobEvent, transitionTaskEvent } from './scheduler.js';
|
|
2
|
+
import { StartAckTimeout } from './adapters.js';
|
|
3
|
+
import { eventFor } from './events.js';
|
|
4
|
+
import { WorkflowEffectExecutor } from './workflow/effects.js';
|
|
5
|
+
import { workflowEffectFailureEvent } from './workflow/contracts.js';
|
|
6
|
+
export function executionEventSource(jobId, taskId, executionId) { return `job/${jobId}/task/${taskId}/execution/${executionId}`; }
|
|
7
|
+
function eventSourceId(event) { return typeof event.source?.id === 'string' ? event.source.id : undefined; }
|
|
8
|
+
const terminalExecutionClaims = new Set();
|
|
9
|
+
function claimTerminalExecution(sourceId) {
|
|
10
|
+
terminalExecutionClaims.add(sourceId);
|
|
11
|
+
const cleanup = setTimeout(() => terminalExecutionClaims.delete(sourceId), 60000);
|
|
12
|
+
cleanup.unref();
|
|
13
|
+
}
|
|
14
|
+
function terminalExecutionClaimed(jobId, taskId, executionId) {
|
|
15
|
+
return terminalExecutionClaims.has(executionEventSource(jobId, taskId, executionId));
|
|
16
|
+
}
|
|
17
|
+
export class ReconcilerRuntimeBase {
|
|
18
|
+
store;
|
|
19
|
+
options;
|
|
20
|
+
live = new Map();
|
|
21
|
+
plannerLive = new Map();
|
|
22
|
+
terminalTimers = new Map();
|
|
23
|
+
terminalExitGraceTimers = new Map();
|
|
24
|
+
continuationLive = new Set();
|
|
25
|
+
launchFailureClaims = new Set();
|
|
26
|
+
terminalWaiters = new Map();
|
|
27
|
+
workflowEffects = new WorkflowEffectExecutor();
|
|
28
|
+
reconciling = false;
|
|
29
|
+
constructor(store, options) {
|
|
30
|
+
this.store = store;
|
|
31
|
+
this.options = options;
|
|
32
|
+
}
|
|
33
|
+
async applyJobEffectOutcomes(job, outcomes) {
|
|
34
|
+
for (const outcome of outcomes.filter(item => item.status === 'failed')) {
|
|
35
|
+
const eventName = workflowEffectFailureEvent(outcome.effect);
|
|
36
|
+
try {
|
|
37
|
+
await transitionJobEvent(job, eventName);
|
|
38
|
+
await this.store.saveJob(job);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
// Some effects are observational for a given state; failure is still emitted below.
|
|
42
|
+
}
|
|
43
|
+
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' }));
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
async applyTaskEffectOutcomes(job, task, outcomes) {
|
|
47
|
+
for (const outcome of outcomes.filter(item => item.status === 'failed')) {
|
|
48
|
+
const eventName = workflowEffectFailureEvent(outcome.effect);
|
|
49
|
+
try {
|
|
50
|
+
await transitionTaskEvent(task, eventName);
|
|
51
|
+
task.error = outcome.error ?? `${outcome.effect} failed`;
|
|
52
|
+
await this.store.saveTask(task);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// The effect failure remains observable even if this state has no feedback transition.
|
|
56
|
+
}
|
|
57
|
+
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' }));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
async handleEvent(event) {
|
|
61
|
+
if (event.type === 'task.started') {
|
|
62
|
+
await this.markWorkerStarted(event);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (['task.completed', 'task.failed', 'task.blocked', 'task.cancelled'].includes(event.type)) {
|
|
66
|
+
await this.applyTerminalEvent(event);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (['review.started', 'review.approved', 'review.changes_required', 'review.blocked'].includes(event.type)) {
|
|
70
|
+
await this.applyReviewEvent(event);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (event.type === 'job.wake' || event.type === 'github.webhook')
|
|
74
|
+
await this.reconcile(event.job_id);
|
|
75
|
+
}
|
|
76
|
+
async watch(jobId) {
|
|
77
|
+
if (!this.options.eventBus)
|
|
78
|
+
return async () => { };
|
|
79
|
+
try {
|
|
80
|
+
return await this.options.eventBus.subscribe(jobId, event => this.handleEvent(event));
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
await this.reportTransportFailure(error);
|
|
84
|
+
return async () => { };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
async idle() { while (this.live.size > 0 || this.plannerLive.size > 0 || this.continuationLive.size > 0 || this.reconciling)
|
|
88
|
+
await new Promise(resolve => setTimeout(resolve, 25)); }
|
|
89
|
+
async wake(jobId) { return this.reconcile(jobId); }
|
|
90
|
+
async startAck(jobId, taskId, sourceId, launchBarrier, timeoutMs = this.options.startAckTimeoutMs ?? 60000) {
|
|
91
|
+
if (!this.options.eventBus)
|
|
92
|
+
throw new Error('Worker start acknowledgement requires an event bus');
|
|
93
|
+
let resolveAck;
|
|
94
|
+
let rejectAck;
|
|
95
|
+
let settled = false;
|
|
96
|
+
let timer;
|
|
97
|
+
const ack = new Promise((resolve, reject) => { resolveAck = resolve; rejectAck = reject; });
|
|
98
|
+
const unsubscribe = await this.options.eventBus.subscribe(jobId, async (event) => {
|
|
99
|
+
if (settled || event.type !== 'task.started' || event.job_id !== jobId || event.task_id !== taskId)
|
|
100
|
+
return;
|
|
101
|
+
if (eventSourceId(event) !== sourceId)
|
|
102
|
+
return;
|
|
103
|
+
if (!event.timestamp || new Date(event.timestamp).getTime() < launchBarrier)
|
|
104
|
+
return;
|
|
105
|
+
settled = true;
|
|
106
|
+
if (timer)
|
|
107
|
+
clearTimeout(timer);
|
|
108
|
+
resolveAck();
|
|
109
|
+
});
|
|
110
|
+
timer = setTimeout(() => {
|
|
111
|
+
if (settled)
|
|
112
|
+
return;
|
|
113
|
+
settled = true;
|
|
114
|
+
void unsubscribe();
|
|
115
|
+
rejectAck(new StartAckTimeout(`Task ${taskId} worker_start_timeout: no worker task.started start-ack`));
|
|
116
|
+
}, timeoutMs);
|
|
117
|
+
let stopped = false;
|
|
118
|
+
const stop = async () => { if (stopped)
|
|
119
|
+
return; stopped = true; if (timer)
|
|
120
|
+
clearTimeout(timer); await unsubscribe(); };
|
|
121
|
+
return { ack, stop };
|
|
122
|
+
}
|
|
123
|
+
clearExecution(taskId, executionId) {
|
|
124
|
+
const live = this.live.get(taskId);
|
|
125
|
+
if (!executionId || live?.executionId === executionId)
|
|
126
|
+
this.live.delete(taskId);
|
|
127
|
+
const timer = this.terminalTimers.get(taskId);
|
|
128
|
+
if (timer)
|
|
129
|
+
clearTimeout(timer);
|
|
130
|
+
this.terminalTimers.delete(taskId);
|
|
131
|
+
const graceTimer = this.terminalExitGraceTimers.get(taskId);
|
|
132
|
+
if (graceTimer)
|
|
133
|
+
clearTimeout(graceTimer);
|
|
134
|
+
this.terminalExitGraceTimers.delete(taskId);
|
|
135
|
+
}
|
|
136
|
+
async markWorkerStarted(event) {
|
|
137
|
+
const live = this.live.get(event.task_id);
|
|
138
|
+
if (live && event.job_id === live.jobId && event.timestamp && new Date(event.timestamp).getTime() < live.launchBarrier)
|
|
139
|
+
return;
|
|
140
|
+
const job = await this.store.load(event.job_id);
|
|
141
|
+
const task = job.tasks.find(item => item.id === event.task_id);
|
|
142
|
+
if (!task || !task.executionId || eventSourceId(event) !== executionEventSource(job.id, task.id, task.executionId))
|
|
143
|
+
return;
|
|
144
|
+
const claimedExecution = task.state === 'QUEUED' && task.executionId !== null && task.leaseOwner !== null;
|
|
145
|
+
const exactLiveExecution = live && event.job_id === live.jobId && task.executionId === live.executionId;
|
|
146
|
+
if ((exactLiveExecution || claimedExecution) && task.state === 'QUEUED') {
|
|
147
|
+
await transitionTaskEvent(task, 'task.started');
|
|
148
|
+
task.failureReason = undefined;
|
|
149
|
+
task.error = null;
|
|
150
|
+
task.updatedAt = event.timestamp || new Date().toISOString();
|
|
151
|
+
await this.store.saveTask(task);
|
|
152
|
+
if (live)
|
|
153
|
+
live.workerStarted = true;
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (task.state !== 'FAILED' || !this.isRecoverableWatchdogFailure(task))
|
|
157
|
+
return;
|
|
158
|
+
if (event.timestamp && new Date(event.timestamp).getTime() < new Date(task.updatedAt).getTime())
|
|
159
|
+
return;
|
|
160
|
+
await transitionTaskEvent(task, 'reconcile.started');
|
|
161
|
+
task.failureReason = undefined;
|
|
162
|
+
task.error = null;
|
|
163
|
+
task.updatedAt = event.timestamp || new Date().toISOString();
|
|
164
|
+
await this.store.saveTask(task);
|
|
165
|
+
this.detach(this.reconcile(job.id), job.id, task.id, 'late worker-start reconciliation');
|
|
166
|
+
}
|
|
167
|
+
isRecoverableWatchdogFailure(task) {
|
|
168
|
+
if (['worker_start_timeout', 'terminal_event_timeout', 'worker_exit_before_start', 'worker_exit_without_terminal_event'].includes(task.failureReason ?? ''))
|
|
169
|
+
return true;
|
|
170
|
+
const error = task.error ?? '';
|
|
171
|
+
return error.includes('worker_start_timeout')
|
|
172
|
+
|| error.includes('timed out waiting for the mandatory terminal worker event')
|
|
173
|
+
|| error.includes('exited before publishing task.started')
|
|
174
|
+
|| error.includes('exited after task.started without publishing a terminal task event');
|
|
175
|
+
}
|
|
176
|
+
isCorrelatedWorkerEvent(task, event) {
|
|
177
|
+
return task.executionId !== null && eventSourceId(event) === executionEventSource(event.job_id, task.id, task.executionId);
|
|
178
|
+
}
|
|
179
|
+
async failLaunch(jobId, taskId, executionId, message, phase = 'launch') {
|
|
180
|
+
const claim = executionId ?? `${jobId}:${taskId}`;
|
|
181
|
+
if (this.launchFailureClaims.has(claim))
|
|
182
|
+
return;
|
|
183
|
+
this.launchFailureClaims.add(claim);
|
|
184
|
+
const cleanup = setTimeout(() => this.launchFailureClaims.delete(claim), 60000);
|
|
185
|
+
cleanup.unref();
|
|
186
|
+
const job = await this.store.load(jobId);
|
|
187
|
+
const task = job.tasks.find(item => item.id === taskId);
|
|
188
|
+
if (!task || task.executionId !== executionId || !['QUEUED', 'RUNNING'].includes(task.state))
|
|
189
|
+
return;
|
|
190
|
+
const event = eventFor(jobId, taskId, task.parentTaskId, 'task.failed', 'failed', `Worker ${phase} failed: ${message}`, 'user', { phase, reason: phase === 'start-ack' ? 'worker_start_timeout' : undefined }, { agent: 'relay', component: 'agents-relay', watchdog: true });
|
|
191
|
+
if (task.state === 'QUEUED') {
|
|
192
|
+
this.clearExecution(task.id, executionId);
|
|
193
|
+
task.error = event.message;
|
|
194
|
+
task.failureReason = phase === 'start-ack' ? 'worker_start_timeout' : undefined;
|
|
195
|
+
task.leaseOwner = null;
|
|
196
|
+
task.leaseExpiresAt = null;
|
|
197
|
+
await transitionTaskEvent(task, 'fail');
|
|
198
|
+
await this.store.saveTask(task);
|
|
199
|
+
}
|
|
200
|
+
else {
|
|
201
|
+
await this.applyTerminalEvent(event, true);
|
|
202
|
+
}
|
|
203
|
+
await this.emit(event);
|
|
204
|
+
}
|
|
205
|
+
async runtimeSettled(jobId, taskId, executionId, result, error) {
|
|
206
|
+
const live = this.live.get(taskId);
|
|
207
|
+
const liveWorkerStarted = live?.executionId === executionId && live.workerStarted;
|
|
208
|
+
if (live?.executionId === executionId)
|
|
209
|
+
this.live.delete(taskId);
|
|
210
|
+
const job = await this.store.load(jobId);
|
|
211
|
+
const task = job.tasks.find(item => item.id === taskId);
|
|
212
|
+
const workerStarted = liveWorkerStarted || task?.state === 'RUNNING';
|
|
213
|
+
if (!task || task.executionId !== executionId)
|
|
214
|
+
return;
|
|
215
|
+
await this.emit(eventFor(jobId, taskId, task.parentTaskId, task.adapter === 'chatgpt' ? 'task.process.async_exited' : 'task.process.exited', error ? 'failed' : 'succeeded', error ?? result?.summary ?? 'Worker runtime exited', 'orchestrator', { authoritative: false, waitingForTerminalEvent: true, processError: error }, { agent: 'relay', component: 'agents-relay' }));
|
|
216
|
+
if (['QUEUED', 'RUNNING'].includes(task.state) && task.adapter === 'codex' && !terminalExecutionClaimed(jobId, taskId, executionId))
|
|
217
|
+
this.scheduleUnexpectedExit(jobId, taskId, executionId, workerStarted === true);
|
|
218
|
+
}
|
|
219
|
+
scheduleUnexpectedExit(jobId, taskId, executionId, workerStarted) {
|
|
220
|
+
const graceMs = Math.max(1, this.options.terminalExitGraceMs ?? 1000);
|
|
221
|
+
const timer = setTimeout(() => {
|
|
222
|
+
this.terminalExitGraceTimers.delete(taskId);
|
|
223
|
+
this.detach(this.failUnexpectedExit(jobId, taskId, executionId, workerStarted), jobId, taskId, 'unexpected worker process exit');
|
|
224
|
+
}, graceMs);
|
|
225
|
+
this.terminalExitGraceTimers.set(taskId, timer);
|
|
226
|
+
}
|
|
227
|
+
async failUnexpectedExit(jobId, taskId, executionId, workerStarted) {
|
|
228
|
+
if (terminalExecutionClaimed(jobId, taskId, executionId))
|
|
229
|
+
return;
|
|
230
|
+
const job = await this.store.load(jobId);
|
|
231
|
+
const task = job.tasks.find(item => item.id === taskId);
|
|
232
|
+
if (!task || task.executionId !== executionId || !['QUEUED', 'RUNNING'].includes(task.state) || terminalExecutionClaimed(jobId, taskId, executionId))
|
|
233
|
+
return;
|
|
234
|
+
const reason = workerStarted ? 'worker_exit_without_terminal_event' : 'worker_exit_before_start';
|
|
235
|
+
const message = workerStarted
|
|
236
|
+
? `Worker ${taskId} exited after task.started without publishing a terminal task event`
|
|
237
|
+
: `Worker ${taskId} exited before publishing task.started`;
|
|
238
|
+
const event = eventFor(jobId, taskId, task.parentTaskId, 'task.failed', 'failed', message, 'user', { reason, terminalGraceMs: this.options.terminalExitGraceMs ?? 1000 }, { agent: 'relay', component: 'agents-relay', watchdog: true });
|
|
239
|
+
if (task.state === 'QUEUED') {
|
|
240
|
+
this.clearExecution(task.id, executionId);
|
|
241
|
+
task.error = message;
|
|
242
|
+
task.failureReason = reason;
|
|
243
|
+
task.leaseOwner = null;
|
|
244
|
+
task.leaseExpiresAt = null;
|
|
245
|
+
await transitionTaskEvent(task, 'fail');
|
|
246
|
+
await this.store.saveTask(task);
|
|
247
|
+
}
|
|
248
|
+
else {
|
|
249
|
+
await this.applyTerminalEvent(event, true);
|
|
250
|
+
}
|
|
251
|
+
await this.emit(event);
|
|
252
|
+
}
|
|
253
|
+
async timeoutTask(jobId, taskId, executionId) {
|
|
254
|
+
const live = this.live.get(taskId);
|
|
255
|
+
if (live?.executionId === executionId) {
|
|
256
|
+
live.controller.abort();
|
|
257
|
+
live.execution.cancel();
|
|
258
|
+
}
|
|
259
|
+
const job = await this.store.load(jobId);
|
|
260
|
+
const task = job.tasks.find(item => item.id === taskId);
|
|
261
|
+
if (!task || task.executionId !== executionId || !['QUEUED', 'RUNNING'].includes(task.state)) {
|
|
262
|
+
this.clearExecution(taskId, executionId);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
const event = eventFor(jobId, taskId, task.parentTaskId, 'task.failed', 'failed', `Task ${taskId} timed out waiting for the mandatory terminal worker event`, 'user', { reason: 'terminal_event_timeout', timeoutMs: task.timeoutMs });
|
|
266
|
+
if (task.state === 'QUEUED') {
|
|
267
|
+
task.error = event.message;
|
|
268
|
+
task.failureReason = 'terminal_event_timeout';
|
|
269
|
+
task.leaseOwner = null;
|
|
270
|
+
task.leaseExpiresAt = null;
|
|
271
|
+
await transitionTaskEvent(task, 'fail');
|
|
272
|
+
await this.store.saveTask(task);
|
|
273
|
+
this.clearExecution(task.id, executionId);
|
|
274
|
+
}
|
|
275
|
+
else {
|
|
276
|
+
await this.applyTerminalEvent(event, true);
|
|
277
|
+
}
|
|
278
|
+
await this.emit(event);
|
|
279
|
+
}
|
|
280
|
+
async applyTerminalEvent(event, trustedInternal = false) {
|
|
281
|
+
const job = await this.store.load(event.job_id);
|
|
282
|
+
const task = job.tasks.find(item => item.id === event.task_id);
|
|
283
|
+
if (!task || !['codex', 'chatgpt', 'orchestrator'].includes(task.adapter))
|
|
284
|
+
return;
|
|
285
|
+
const modelBacked = task.adapter === 'codex' || task.adapter === 'chatgpt';
|
|
286
|
+
if (!trustedInternal && modelBacked && !this.isCorrelatedWorkerEvent(task, event))
|
|
287
|
+
return;
|
|
288
|
+
if (task.state === 'FAILED' && this.isRecoverableWatchdogFailure(task) && (trustedInternal || !modelBacked || this.isCorrelatedWorkerEvent(task, event))) {
|
|
289
|
+
if (event.timestamp && new Date(event.timestamp).getTime() < new Date(task.updatedAt).getTime())
|
|
290
|
+
return;
|
|
291
|
+
await transitionTaskEvent(task, 'reconcile.started');
|
|
292
|
+
task.failureReason = undefined;
|
|
293
|
+
task.error = null;
|
|
294
|
+
}
|
|
295
|
+
if (task.state !== 'RUNNING')
|
|
296
|
+
return;
|
|
297
|
+
const terminalWaiter = this.terminalWaiters.get(task.id);
|
|
298
|
+
if (terminalWaiter && (!event.timestamp || new Date(event.timestamp).getTime() < terminalWaiter.launchBarrier))
|
|
299
|
+
return;
|
|
300
|
+
const acceptedExecutionId = task.executionId;
|
|
301
|
+
if (modelBacked && acceptedExecutionId)
|
|
302
|
+
claimTerminalExecution(executionEventSource(job.id, task.id, acceptedExecutionId));
|
|
303
|
+
this.clearExecution(task.id, acceptedExecutionId);
|
|
304
|
+
task.leaseOwner = null;
|
|
305
|
+
task.leaseExpiresAt = null;
|
|
306
|
+
task.updatedAt = event.timestamp || new Date().toISOString();
|
|
307
|
+
this.terminalWaiters.get(task.id)?.resolve();
|
|
308
|
+
this.terminalWaiters.delete(task.id);
|
|
309
|
+
if (event.type === 'task.completed') {
|
|
310
|
+
task.result = { summary: event.message || `Task ${task.id} completed`, data: event.data };
|
|
311
|
+
task.error = null;
|
|
312
|
+
task.failureReason = undefined;
|
|
313
|
+
await transitionTaskEvent(task, 'task.completed');
|
|
314
|
+
await this.store.saveTask(task);
|
|
315
|
+
await this.deliverContinuation(job, task);
|
|
316
|
+
}
|
|
317
|
+
else if (event.type === 'task.failed') {
|
|
318
|
+
task.error = event.message || `Task ${task.id} failed`;
|
|
319
|
+
task.failureReason = typeof event.data?.reason === 'string' ? event.data.reason : undefined;
|
|
320
|
+
await transitionTaskEvent(task, 'task.failed');
|
|
321
|
+
await this.store.saveTask(task);
|
|
322
|
+
}
|
|
323
|
+
else if (event.type === 'task.blocked') {
|
|
324
|
+
task.error = event.message || `Task ${task.id} blocked`;
|
|
325
|
+
await transitionTaskEvent(task, 'task.blocked');
|
|
326
|
+
await this.store.saveTask(task);
|
|
327
|
+
}
|
|
328
|
+
else if (event.type === 'task.cancelled') {
|
|
329
|
+
task.error = event.message || `Task ${task.id} cancelled`;
|
|
330
|
+
await transitionTaskEvent(task, 'task.cancelled');
|
|
331
|
+
await this.store.saveTask(task);
|
|
332
|
+
}
|
|
333
|
+
this.detach(this.reconcile(job.id), job.id, task.id, 'terminal-event reconciliation');
|
|
334
|
+
}
|
|
335
|
+
terminalWait(taskId, executionId, launchBarrier) {
|
|
336
|
+
let resolve;
|
|
337
|
+
const promise = new Promise(resolver => { resolve = resolver; });
|
|
338
|
+
this.terminalWaiters.set(taskId, { executionId, launchBarrier, resolve });
|
|
339
|
+
return {
|
|
340
|
+
promise,
|
|
341
|
+
cancel: () => {
|
|
342
|
+
const current = this.terminalWaiters.get(taskId);
|
|
343
|
+
if (current?.executionId === executionId)
|
|
344
|
+
this.terminalWaiters.delete(taskId);
|
|
345
|
+
},
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
async deliverContinuation(job, task) {
|
|
349
|
+
const continuation = task.continuation ?? job.continuation;
|
|
350
|
+
if (!continuation || task.continuationDeliveredAt || this.continuationLive.has(task.id))
|
|
351
|
+
return;
|
|
352
|
+
const adapter = this.options.continuations?.find(candidate => candidate.kind === continuation.kind);
|
|
353
|
+
if (!adapter) {
|
|
354
|
+
task.error = `No continuation adapter for ${continuation.kind}`;
|
|
355
|
+
await this.store.saveTask(task);
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
this.continuationLive.add(task.id);
|
|
359
|
+
try {
|
|
360
|
+
await adapter.continue(task.result?.summary ?? '', continuation);
|
|
361
|
+
const fresh = await this.store.load(job.id);
|
|
362
|
+
const durable = fresh.tasks.find(item => item.id === task.id);
|
|
363
|
+
if (durable?.state === 'SUCCEEDED' && !durable.continuationDeliveredAt) {
|
|
364
|
+
durable.continuationDeliveredAt = new Date().toISOString();
|
|
365
|
+
await this.store.saveTask(durable);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
catch (error) {
|
|
369
|
+
await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'continuation.failed', 'failed', `Continuation failed: ${error instanceof Error ? error.message : String(error)}`, 'user'));
|
|
370
|
+
}
|
|
371
|
+
finally {
|
|
372
|
+
this.continuationLive.delete(task.id);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
async emit(event) { try {
|
|
376
|
+
if (this.options.emit)
|
|
377
|
+
await this.options.emit(event);
|
|
378
|
+
}
|
|
379
|
+
catch { /* observability cannot block durable execution */ } try {
|
|
380
|
+
if (this.options.eventBus)
|
|
381
|
+
await this.options.eventBus.publish(event);
|
|
382
|
+
}
|
|
383
|
+
catch (error) {
|
|
384
|
+
await this.reportTransportFailure(error);
|
|
385
|
+
} }
|
|
386
|
+
detach(promise, jobId, taskId, context) {
|
|
387
|
+
void promise.catch(error => this.reportDetachedFailure(jobId, taskId, context, error));
|
|
388
|
+
}
|
|
389
|
+
async reportDetachedFailure(jobId, taskId, context, error) {
|
|
390
|
+
if (!this.options.emit)
|
|
391
|
+
return;
|
|
392
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
393
|
+
try {
|
|
394
|
+
await this.options.emit(eventFor(jobId, taskId, null, 'runtime.persistence.failed', 'failed', `${context} failed: ${message}`, 'orchestrator'));
|
|
395
|
+
}
|
|
396
|
+
catch { /* detached failure reporting must never crash the daemon */ }
|
|
397
|
+
}
|
|
398
|
+
async reportTransportFailure(error) { if (this.options.emit) {
|
|
399
|
+
try {
|
|
400
|
+
await this.options.emit(eventFor('system', 'event-bus', null, 'event.transport.failed', 'failed', `Event transport unavailable: ${error instanceof Error ? error.message : String(error)}`, 'orchestrator'));
|
|
401
|
+
}
|
|
402
|
+
catch { /* reporting is best effort */ }
|
|
403
|
+
} }
|
|
404
|
+
}
|