agents-relay 2.0.60 → 2.0.62
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/pool.js +7 -0
- package/dist/reconciler-runtime.js +33 -52
- package/dist/worker-start-ack.js +42 -0
- package/package.json +1 -1
package/dist/pool.js
CHANGED
|
@@ -138,6 +138,13 @@ export class RepositoryWorkerPool {
|
|
|
138
138
|
continue;
|
|
139
139
|
}
|
|
140
140
|
const result = await withGitHubRequestContext(this.client, `pool.coordinator-apply:${signal.reason}`, () => reconciler.applyEvent(signal.event));
|
|
141
|
+
if (!result.applied)
|
|
142
|
+
observability().record({
|
|
143
|
+
stage: 'transition', event: 'lifecycle.event_rejected', level: 'warn', outcome: 'skipped',
|
|
144
|
+
message: `Lifecycle event ${signal.event.type} not applied: ${result.reason}`,
|
|
145
|
+
correlation: { repository: target.repository, prNumber: target.prNumber, jobId, taskId: signal.event.task_id },
|
|
146
|
+
data: { eventId: signal.event.event_id, sourceId: signal.event.source?.id, reason: result.reason, eventTimestamp: signal.event.timestamp },
|
|
147
|
+
});
|
|
141
148
|
shouldReconcile ||= result.reconcile;
|
|
142
149
|
}
|
|
143
150
|
if (!shouldReconcile) {
|
|
@@ -1,12 +1,13 @@
|
|
|
1
|
+
import { applyTaskRetryAction } from './task-actions.js';
|
|
1
2
|
import { transitionJobEvent, transitionTaskEvent } from './scheduler.js';
|
|
2
3
|
import { DurableTaskTransitionRuntime } from './workflow/durable-transition.js';
|
|
3
|
-
import { StartAckTimeout } from './adapters.js';
|
|
4
4
|
import { eventFor } from './events.js';
|
|
5
5
|
import { WorkflowEffectExecutor } from './workflow/effects.js';
|
|
6
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';
|
|
10
|
+
import { createWorkerStartAck } from './worker-start-ack.js';
|
|
10
11
|
import { monitorExecutionProcess } from './execution-process-tracker.js';
|
|
11
12
|
export function executionEventSource(jobId, taskId, executionId) { return `job/${jobId}/task/${taskId}/execution/${executionId}`; }
|
|
12
13
|
function eventSourceId(event) { return typeof event.source?.id === 'string' ? event.source.id : undefined; }
|
|
@@ -183,46 +184,7 @@ export class ReconcilerRuntimeBase {
|
|
|
183
184
|
async startAck(jobId, taskId, sourceId, launchBarrier, timeoutMs = this.options.startAckTimeoutMs ?? 60000) {
|
|
184
185
|
if (!this.options.eventBus)
|
|
185
186
|
throw new Error('Worker start acknowledgement requires an event bus');
|
|
186
|
-
|
|
187
|
-
let rejectAck;
|
|
188
|
-
let settled = false;
|
|
189
|
-
let timer;
|
|
190
|
-
const ack = new Promise((resolve, reject) => { resolveAck = resolve; rejectAck = reject; });
|
|
191
|
-
const unsubscribe = await this.options.eventBus.subscribe(jobId, async (event) => {
|
|
192
|
-
if (settled || event.type !== 'task.started' || event.job_id !== jobId || event.task_id !== taskId)
|
|
193
|
-
return;
|
|
194
|
-
if (eventSourceId(event) !== sourceId)
|
|
195
|
-
return;
|
|
196
|
-
if (!event.timestamp || new Date(event.timestamp).getTime() < launchBarrier)
|
|
197
|
-
return;
|
|
198
|
-
settled = true;
|
|
199
|
-
if (timer)
|
|
200
|
-
clearTimeout(timer);
|
|
201
|
-
resolveAck();
|
|
202
|
-
});
|
|
203
|
-
timer = setTimeout(() => {
|
|
204
|
-
if (settled)
|
|
205
|
-
return;
|
|
206
|
-
settled = true;
|
|
207
|
-
void unsubscribe();
|
|
208
|
-
rejectAck(new StartAckTimeout(`Task ${taskId} worker_start_timeout: no worker task.started start-ack`));
|
|
209
|
-
}, timeoutMs);
|
|
210
|
-
let stopped = false;
|
|
211
|
-
const stop = async () => {
|
|
212
|
-
if (stopped)
|
|
213
|
-
return;
|
|
214
|
-
stopped = true;
|
|
215
|
-
if (timer)
|
|
216
|
-
clearTimeout(timer);
|
|
217
|
-
if (!settled) {
|
|
218
|
-
settled = true;
|
|
219
|
-
rejectAck(new StartAckTimeout(`Task ${taskId} worker_start_cancelled: start-ack owner closed`));
|
|
220
|
-
}
|
|
221
|
-
this.startAckCleanups.delete(stop);
|
|
222
|
-
await unsubscribe();
|
|
223
|
-
};
|
|
224
|
-
this.startAckCleanups.add(stop);
|
|
225
|
-
return { ack, stop };
|
|
187
|
+
return createWorkerStartAck(this.options.eventBus, jobId, taskId, sourceId, timeoutMs, this.startAckCleanups);
|
|
226
188
|
}
|
|
227
189
|
registerExecutionOwner(jobId, taskId, executionId) {
|
|
228
190
|
activeExecutionOwners.set(executionEventSource(jobId, taskId, executionId), this);
|
|
@@ -269,8 +231,7 @@ export class ReconcilerRuntimeBase {
|
|
|
269
231
|
}
|
|
270
232
|
async markWorkerStarted(event) {
|
|
271
233
|
const live = this.live.get(event.task_id);
|
|
272
|
-
|
|
273
|
-
return { applied: false, reconcile: false, reason: 'task.started.stale' };
|
|
234
|
+
// Event timestamps are worker-authored and may be wrong; exact execution identity is authoritative.
|
|
274
235
|
const transition = await this.taskTransitions.apply(event.job_id, event.task_id, 'task.started', {
|
|
275
236
|
eligible: (job, task) => {
|
|
276
237
|
if (!task.executionId || eventSourceId(event) !== executionEventSource(job.id, task.id, task.executionId))
|
|
@@ -281,10 +242,10 @@ export class ReconcilerRuntimeBase {
|
|
|
281
242
|
},
|
|
282
243
|
facts: (_job, task) => ({
|
|
283
244
|
recoverableExecution: this.isRecoverableWatchdogFailure(task)
|
|
284
|
-
&& (
|
|
245
|
+
&& Boolean(task.executionId && eventSourceId(event) === executionEventSource(event.job_id, task.id, task.executionId)),
|
|
285
246
|
}),
|
|
286
247
|
mutate: (_job, task) => {
|
|
287
|
-
task.updatedAt =
|
|
248
|
+
task.updatedAt = new Date().toISOString();
|
|
288
249
|
},
|
|
289
250
|
});
|
|
290
251
|
if (!transition?.decision.accepted)
|
|
@@ -335,8 +296,10 @@ export class ReconcilerRuntimeBase {
|
|
|
335
296
|
}
|
|
336
297
|
async runtimeSettled(jobId, taskId, executionId, result, error) {
|
|
337
298
|
const live = this.live.get(taskId);
|
|
338
|
-
if (live?.executionId === executionId)
|
|
299
|
+
if (live?.executionId === executionId) {
|
|
339
300
|
live.processSettled = true;
|
|
301
|
+
live.processError = error;
|
|
302
|
+
}
|
|
340
303
|
const job = await this.store.load(jobId);
|
|
341
304
|
const task = job.tasks.find(item => item.id === taskId);
|
|
342
305
|
if (!task || (task.executionId !== executionId && !terminalExecutionClaimed(jobId, taskId, executionId)))
|
|
@@ -352,12 +315,17 @@ export class ReconcilerRuntimeBase {
|
|
|
352
315
|
live.controller.abort();
|
|
353
316
|
live.execution.cancel();
|
|
354
317
|
}
|
|
318
|
+
const exit = live?.executionId === executionId && live.processSettled ? live.execution.processExit : undefined;
|
|
319
|
+
const rawError = live?.executionId === executionId && live.processSettled ? (live.processError || exit?.stderr || '').trim() : '';
|
|
320
|
+
const failedExit = Boolean(exit && (exit.exitCode !== 0 || exit.signal));
|
|
321
|
+
const failureReason = failedExit ? (/rate.limit.exceeded|usage limit reached/i.test(rawError) ? 'rate_limit_exceeded' : 'worker_process_failed') : 'terminal_event_timeout';
|
|
322
|
+
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`;
|
|
355
323
|
const transition = await this.taskTransitions.apply(jobId, taskId, 'watchdog.failed', {
|
|
356
324
|
eligible: (_job, task) => task.executionId === executionId,
|
|
357
325
|
mutate: (_job, task) => {
|
|
358
326
|
this.clearExecution(task.id, executionId);
|
|
359
|
-
task.error =
|
|
360
|
-
task.failureReason =
|
|
327
|
+
task.error = failureMessage;
|
|
328
|
+
task.failureReason = failureReason;
|
|
361
329
|
task.leaseOwner = null;
|
|
362
330
|
task.leaseExpiresAt = null;
|
|
363
331
|
},
|
|
@@ -366,8 +334,21 @@ export class ReconcilerRuntimeBase {
|
|
|
366
334
|
this.clearExecution(taskId, executionId);
|
|
367
335
|
return;
|
|
368
336
|
}
|
|
369
|
-
const event = eventFor(jobId, taskId, transition.task.parentTaskId, 'task.failed', 'failed', transition.task.error ??
|
|
370
|
-
this.
|
|
337
|
+
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' }, { agent: 'relay', component: 'agents-relay', watchdog: true });
|
|
338
|
+
await this.emit(event);
|
|
339
|
+
// Provider quota failures are recoverable. Drop the stale selection so the
|
|
340
|
+
// next launch runs the normal worker/model router with fresh usage data.
|
|
341
|
+
if (failureReason === 'rate_limit_exceeded' && transition.task.attempt < transition.task.maxAttempts) {
|
|
342
|
+
const current = (await this.store.load(jobId)).tasks.find(task => task.id === taskId);
|
|
343
|
+
if (current?.state === 'FAILED' && current.failureReason === failureReason && current.attempt === transition.task.attempt) {
|
|
344
|
+
await applyTaskRetryAction(current);
|
|
345
|
+
current.routing = undefined;
|
|
346
|
+
current.workerSelection = undefined;
|
|
347
|
+
await this.store.saveTask(current);
|
|
348
|
+
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 }));
|
|
349
|
+
await this.eventCoordinator.accept({ jobId, reason: 'rate-limit-reroute' });
|
|
350
|
+
}
|
|
351
|
+
}
|
|
371
352
|
}
|
|
372
353
|
async applyTerminalEvent(event, trustedInternal = false) {
|
|
373
354
|
const liveAtTerminal = this.live.get(event.task_id);
|
|
@@ -388,7 +369,7 @@ export class ReconcilerRuntimeBase {
|
|
|
388
369
|
return false;
|
|
389
370
|
}
|
|
390
371
|
const terminalWaiter = this.terminalWaiters.get(task.id);
|
|
391
|
-
return !terminalWaiter || Boolean(
|
|
372
|
+
return !terminalWaiter || Boolean(task.executionId && terminalWaiter.executionId === task.executionId && this.isCorrelatedWorkerEvent(task, event));
|
|
392
373
|
},
|
|
393
374
|
facts: (_job, task) => {
|
|
394
375
|
const processExitedAfterPublishedTerminal = task.failureReason === 'worker_exit_without_terminal_event';
|
|
@@ -411,7 +392,7 @@ export class ReconcilerRuntimeBase {
|
|
|
411
392
|
task.leaseOwner = null;
|
|
412
393
|
task.leaseExpiresAt = null;
|
|
413
394
|
task.executionId = null;
|
|
414
|
-
task.updatedAt =
|
|
395
|
+
task.updatedAt = new Date().toISOString();
|
|
415
396
|
this.terminalWaiters.get(task.id)?.resolve();
|
|
416
397
|
this.terminalWaiters.delete(task.id);
|
|
417
398
|
const reportedTag = acceptedReportedTag(job, event.data);
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { StartAckTimeout } from './adapters.js';
|
|
2
|
+
/** A worker start ack is scoped to its server-issued execution source, never its client clock. */
|
|
3
|
+
export async function createWorkerStartAck(bus, jobId, taskId, sourceId, timeoutMs, cleanups) {
|
|
4
|
+
let resolveAck;
|
|
5
|
+
let rejectAck;
|
|
6
|
+
let settled = false;
|
|
7
|
+
let timer;
|
|
8
|
+
const ack = new Promise((resolve, reject) => { resolveAck = resolve; rejectAck = reject; });
|
|
9
|
+
const unsubscribe = await bus.subscribe(jobId, async (event) => {
|
|
10
|
+
if (settled || event.type !== 'task.started' || event.job_id !== jobId || event.task_id !== taskId)
|
|
11
|
+
return;
|
|
12
|
+
if (event.source?.id !== sourceId)
|
|
13
|
+
return;
|
|
14
|
+
settled = true;
|
|
15
|
+
if (timer)
|
|
16
|
+
clearTimeout(timer);
|
|
17
|
+
resolveAck();
|
|
18
|
+
});
|
|
19
|
+
timer = setTimeout(() => {
|
|
20
|
+
if (settled)
|
|
21
|
+
return;
|
|
22
|
+
settled = true;
|
|
23
|
+
void unsubscribe();
|
|
24
|
+
rejectAck(new StartAckTimeout(`Task ${taskId} worker_start_timeout: no worker task.started start-ack`));
|
|
25
|
+
}, timeoutMs);
|
|
26
|
+
let stopped = false;
|
|
27
|
+
const stop = async () => {
|
|
28
|
+
if (stopped)
|
|
29
|
+
return;
|
|
30
|
+
stopped = true;
|
|
31
|
+
if (timer)
|
|
32
|
+
clearTimeout(timer);
|
|
33
|
+
if (!settled) {
|
|
34
|
+
settled = true;
|
|
35
|
+
rejectAck(new StartAckTimeout(`Task ${taskId} worker_start_cancelled: start-ack owner closed`));
|
|
36
|
+
}
|
|
37
|
+
cleanups.delete(stop);
|
|
38
|
+
await unsubscribe();
|
|
39
|
+
};
|
|
40
|
+
cleanups.add(stop);
|
|
41
|
+
return { ack, stop };
|
|
42
|
+
}
|