agents-relay 2.0.139 → 2.0.140

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/adapters.js CHANGED
@@ -6,6 +6,7 @@ import { AsyncLocalStorage } from 'node:async_hooks';
6
6
  import { withTestInjection } from './e2e-injection.js';
7
7
  import { taskReferencePrompt } from './task-context.js';
8
8
  import { spawnOwnedProcess, terminateOwnedProcess, ownedProcessTerminationReason } from './owned-process.js';
9
+ import { PreSubmissionRejection } from './worker-rejection.js';
9
10
  export function buildCodexArgs(route, input) {
10
11
  const args = ['exec', '--json'];
11
12
  if (route.profile)
@@ -202,8 +203,14 @@ function rawBrowserWorkspaceCommand(cli, args, stdin, signal) {
202
203
  try {
203
204
  const envelope = parseWorkspaceJson(stdout);
204
205
  const result = envelope.result;
205
- if (result?.ok === false && typeof result.code === 'string' && typeof result.message === 'string')
206
+ if (result?.ok === false && typeof result.code === 'string' && typeof result.message === 'string') {
206
207
  reason = `${result.code}: ${result.message}`;
208
+ const rejection = PreSubmissionRejection.fromPlatformResponse(result);
209
+ if (rejection) {
210
+ reject(rejection);
211
+ return;
212
+ }
213
+ }
207
214
  }
208
215
  catch { /* Preserve legacy error handling for other CLI failures. */ }
209
216
  reject(new Error(reason));
@@ -387,6 +394,8 @@ export class ChatGptAdapter {
387
394
  throw error;
388
395
  if (error instanceof StartAckTimeout)
389
396
  throw error;
397
+ if (error instanceof PreSubmissionRejection)
398
+ throw error;
390
399
  throw new LaunchFailure(error instanceof Error ? error.message : String(error));
391
400
  }
392
401
  })();
@@ -0,0 +1,38 @@
1
+ import { eligibleWorkerCandidates, workerCandidates } from './worker-routing.js';
2
+ import { routeExecution } from './execution-routing.js';
3
+ export async function codexFallbackCompatible(task, adapters, registered, usage) {
4
+ if (task.adapter !== 'chatgpt' || task.chatgptThreadDelivery || task.workerRequirements?.runtime === 'chatgpt')
5
+ return false;
6
+ const requirements = task.workerRequirements ?? { capabilities: [] };
7
+ const candidates = eligibleWorkerCandidates(workerCandidates(adapters, registered, usage), requirements, { strictCapabilities: true })
8
+ .filter(candidate => candidate.adapter === 'codex');
9
+ if (candidates.length === 0)
10
+ return false;
11
+ try {
12
+ await routeExecution(candidates, requirements, { registered, usage, taskFacts: { task } });
13
+ return true;
14
+ }
15
+ catch {
16
+ return false;
17
+ }
18
+ }
19
+ export function persistChatGptPlatformFailure(task, original, executionId, classification, evidence, safeToReroute) {
20
+ const route = original?.routing;
21
+ const workerId = original?.workerSelection?.workerId ?? 'chatgpt';
22
+ const at = new Date().toISOString();
23
+ task.platformFailure = {
24
+ classification,
25
+ executionId: executionId ?? 'unknown',
26
+ route: { workerId, adapter: 'chatgpt', provider: route?.provider ?? 'unknown', model: route?.model ?? 'unknown', ...(route?.profile ? { profile: route.profile } : {}) },
27
+ evidence,
28
+ safeToReroute,
29
+ at,
30
+ };
31
+ if (!task.routingExclusions?.some(item => item.workerId === workerId && item.adapter === 'chatgpt')) {
32
+ task.routingExclusions = [...(task.routingExclusions ?? []), {
33
+ ...task.platformFailure.route,
34
+ reason: classification,
35
+ at,
36
+ }];
37
+ }
38
+ }
@@ -0,0 +1,15 @@
1
+ export function executionEventSource(jobId, taskId, executionId) { return `job/${jobId}/task/${taskId}/execution/${executionId}`; }
2
+ export function eventSourceId(event) { return typeof event.source?.id === 'string' ? event.source.id : undefined; }
3
+ export function executionIdFromSource(sourceId, jobId, taskId) {
4
+ const prefix = `job/${jobId}/task/${taskId}/execution/`;
5
+ return sourceId?.startsWith(prefix) ? sourceId.slice(prefix.length) || undefined : undefined;
6
+ }
7
+ const terminalExecutionClaims = new Set();
8
+ export function claimTerminalExecution(sourceId) {
9
+ terminalExecutionClaims.add(sourceId);
10
+ const cleanup = setTimeout(() => terminalExecutionClaims.delete(sourceId), 60000);
11
+ cleanup.unref();
12
+ }
13
+ export function terminalExecutionClaimed(jobId, taskId, executionId) {
14
+ return terminalExecutionClaims.has(executionEventSource(jobId, taskId, executionId));
15
+ }
@@ -268,7 +268,12 @@ export class ReconcilerReviewBase extends ReconcilerRuntimeBase {
268
268
  return { task, routed: false };
269
269
  const usage = this.options.usageSnapshotProvider ? await this.options.usageSnapshotProvider() : undefined;
270
270
  const registered = await this.store.listAgents();
271
- const candidates = workerCandidates(this.options.adapters, registered, usage).filter(candidate => (implementationThread || reviewerThread) ? candidate.adapter === 'chatgpt' : true);
271
+ const excludedWorkers = new Set((task.routingExclusions ?? []).map(item => item.workerId).filter((id) => Boolean(id)));
272
+ const excludedAdapters = new Set((task.routingExclusions ?? []).map(item => item.adapter).filter((adapter) => Boolean(adapter)));
273
+ const candidates = workerCandidates(this.options.adapters, registered, usage)
274
+ .filter(candidate => !excludedWorkers.has(candidate.workerId))
275
+ .filter(candidate => !excludedAdapters.has(candidate.adapter))
276
+ .filter(candidate => (implementationThread || reviewerThread) ? candidate.adapter === 'chatgpt' : true);
272
277
  const runtimeSummary = this.options.runtimeSummaryProvider?.();
273
278
  const requirements = (implementationThread || reviewerThread) ? chatgptThreadRequirements(task.workerRequirements) : (task.workerRequirements ?? { capabilities: [] });
274
279
  const taskFacts = {
@@ -1,6 +1,8 @@
1
1
  import { normalizeReviewerTerminalEvent } from './review-terminal-normalization.js';
2
+ import { executionEventSource, eventSourceId, executionIdFromSource, claimTerminalExecution, terminalExecutionClaimed } from './execution-event-identity.js';
3
+ export { executionEventSource } from './execution-event-identity.js';
2
4
  import { applyJobEffectOutcomes, applyTaskEffectOutcomes } from './workflow-effect-failures.js';
3
- import { classifyWorkerRejection, reroutableWorkerRejections } from './worker-rejection.js';
5
+ import { classifyChatGptPlatformFailure, classifyWorkerRejection, reroutableWorkerRejections } from './worker-rejection.js';
4
6
  import { applyTaskRetryAction } from './task-actions.js';
5
7
  import { DurableTaskTransitionRuntime } from './workflow/durable-transition.js';
6
8
  import { eventFor } from './events.js';
@@ -12,24 +14,10 @@ import { createWorkerStartAck } from './worker-start-ack.js';
12
14
  import { monitorExecutionProcess } from './execution-process-tracker.js';
13
15
  import { HostInteractiveInvocationCoordinator, hostInvocationPorts } from './interactive-invocation.js';
14
16
  import { resumeInteractiveTask } from './interactive-resume.js';
15
- export function executionEventSource(jobId, taskId, executionId) { return `job/${jobId}/task/${taskId}/execution/${executionId}`; }
16
- function eventSourceId(event) { return typeof event.source?.id === 'string' ? event.source.id : undefined; }
17
- function executionIdFromSource(sourceId, jobId, taskId) {
18
- const prefix = `job/${jobId}/task/${taskId}/execution/`;
19
- return sourceId?.startsWith(prefix) ? sourceId.slice(prefix.length) || undefined : undefined;
20
- }
21
- const terminalExecutionClaims = new Set();
17
+ import { codexFallbackCompatible, persistChatGptPlatformFailure } from './chatgpt-platform-fallback.js';
22
18
  const activeExecutionOwners = new Map();
23
19
  import { withExecutionBoundary } from './execution-boundary.js';
24
20
  export { withExecutionBoundary } from './execution-boundary.js';
25
- function claimTerminalExecution(sourceId) {
26
- terminalExecutionClaims.add(sourceId);
27
- const cleanup = setTimeout(() => terminalExecutionClaims.delete(sourceId), 60000);
28
- cleanup.unref();
29
- }
30
- function terminalExecutionClaimed(jobId, taskId, executionId) {
31
- return terminalExecutionClaims.has(executionEventSource(jobId, taskId, executionId));
32
- }
33
21
  export class ReconcilerRuntimeBase {
34
22
  store;
35
23
  options;
@@ -271,7 +259,11 @@ export class ReconcilerRuntimeBase {
271
259
  }
272
260
  return true;
273
261
  }
274
- async failLaunch(jobId, taskId, executionId, message, phase = 'launch') {
262
+ async failLaunch(jobId, taskId, executionId, failure, phase = 'launch') {
263
+ const message = failure instanceof Error ? failure.message : String(failure);
264
+ const currentJob = await this.store.load(jobId);
265
+ const currentTask = currentJob.tasks.find(task => task.id === taskId);
266
+ const platformFailure = currentTask?.adapter === 'chatgpt' ? classifyChatGptPlatformFailure(failure, phase) : undefined;
275
267
  const failureReason = phase === 'start-ack' ? 'worker_start_timeout'
276
268
  : /BROWSER_WORKSPACE_UNAVAILABLE|browser-workspace runtime dependency unavailable|browser-workspace session start returned no session_id|no close frame received or sent/i.test(message)
277
269
  ? 'browser_workspace_unavailable'
@@ -282,13 +274,20 @@ export class ReconcilerRuntimeBase {
282
274
  this.launchFailureClaims.add(claim);
283
275
  const cleanup = setTimeout(() => this.launchFailureClaims.delete(claim), 60000);
284
276
  cleanup.unref();
285
- const workflowEvent = phase === 'start-ack' ? 'worker.start_ack_failed' : 'worker.delivery_failed';
286
- const transition = await this.taskTransitions.apply(jobId, taskId, workflowEvent, {
277
+ const workflowEvent = platformFailure ? 'worker.platform_failure' : phase === 'start-ack' ? 'worker.start_ack_failed' : 'worker.delivery_failed';
278
+ const usage = this.options.usageSnapshotProvider ? await this.options.usageSnapshotProvider() : undefined;
279
+ const platformWorkerCompatible = Boolean(platformFailure?.safeToReroute && currentTask && !(currentTask.agentName === 'reviewer' && this.options.reviewer?.threadId)
280
+ && await codexFallbackCompatible(currentTask, this.options.adapters, await this.store.listAgents(), usage));
281
+ const transition = await this.taskTransitions.apply(jobId, taskId, platformFailure ? 'worker.platform_failure' : workflowEvent, {
287
282
  eligible: (_job, task) => task.executionId === executionId,
288
- mutate: (_job, task) => {
289
- this.clearExecution(task.id, executionId);
290
- task.error = `Worker ${phase} failed: ${message}`;
291
- task.failureReason = failureReason;
283
+ facts: (_job, task) => ({ platformFallbackSafe: task.state === 'QUEUED' && Boolean(platformFailure?.safeToReroute && platformWorkerCompatible && task.attempt < task.maxAttempts) }),
284
+ mutate: (_job, task, decision) => {
285
+ if (platformFailure)
286
+ persistChatGptPlatformFailure(task, currentTask, executionId, platformFailure.classification, platformFailure.evidence, platformFailure.safeToReroute);
287
+ task.error = decision.target === 'QUEUED' ? null : `Worker ${phase} failed: ${message}`;
288
+ task.failureReason = decision.target === 'BLOCKED'
289
+ ? (task.platformFailure?.safeToReroute && currentTask?.state === 'QUEUED' ? 'worker_platform_fallback_unavailable' : 'worker_outcome_unknown')
290
+ : failureReason;
292
291
  task.leaseOwner = null;
293
292
  task.leaseExpiresAt = null;
294
293
  },
@@ -299,21 +298,19 @@ export class ReconcilerRuntimeBase {
299
298
  await this.emit(eventFor(jobId, taskId, transition.task.parentTaskId, 'task.process.delivery_failed', 'failed', `Worker ${phase} reported after lifecycle moved to ${transition.from}: ${message}`, 'orchestrator', { phase, authoritative: false, executionId, workflowEvent }, { agent: 'relay', component: 'agents-relay' }));
300
299
  return;
301
300
  }
302
- let runtimeRetry = null;
303
- if (failureReason === 'browser_workspace_unavailable' && transition.task.attempt < transition.task.maxAttempts) {
304
- const current = (await this.store.load(jobId)).tasks.find(task => task.id === taskId);
305
- if (current?.state === 'FAILED' && current.failureReason === failureReason && current.attempt === transition.task.attempt) {
306
- await applyTaskRetryAction(current);
307
- current.routing = undefined;
308
- current.workerSelection = undefined;
309
- await this.store.saveTask(current);
310
- runtimeRetry = current;
301
+ if (executionId) {
302
+ const live = this.live.get(taskId);
303
+ if (live?.executionId === executionId) {
304
+ live.controller.abort();
305
+ live.execution.cancel();
306
+ this.clearExecution(taskId, executionId);
311
307
  }
312
308
  }
313
- const event = eventFor(jobId, taskId, transition.task.parentTaskId, 'task.failed', 'failed', transition.task.error ?? `Worker ${phase} failed: ${message}`, 'user', { phase, reason: failureReason, workflowEvent }, { agent: 'relay', component: 'agents-relay', watchdog: true });
309
+ const runtimeRetry = transition.decision.target === 'QUEUED' ? transition.task : null;
310
+ const event = eventFor(jobId, taskId, transition.task.parentTaskId, 'task.failed', 'failed', transition.task.error ?? `Worker ${phase} failed: ${message}`, 'user', { phase, reason: transition.task.failureReason ?? failureReason, workflowEvent, ...(transition.task.platformFailure ? { platformFailure: transition.task.platformFailure } : {}) }, { agent: 'relay', component: 'agents-relay', watchdog: true });
314
311
  this.detach(this.emit(event), jobId, taskId, 'worker failure event');
315
312
  if (runtimeRetry) {
316
- this.detach(this.emit(eventFor(jobId, taskId, runtimeRetry.parentTaskId, 'task.runtime_retry', 'queued', 'Browser Workspace runtime unavailable; retrying the same Task', 'orchestrator', { previousFailure: failureReason, attempt: runtimeRetry.attempt, maxAttempts: runtimeRetry.maxAttempts })), jobId, taskId, 'runtime retry event');
313
+ this.detach(this.emit(eventFor(jobId, taskId, runtimeRetry.parentTaskId, 'task.rerouting', 'queued', 'ChatGPT platform rejection recorded; routing the same Task through Execution Router', 'orchestrator', { previousFailure: transition.task.platformFailure?.classification, executionId, attempt: runtimeRetry.attempt, maxAttempts: runtimeRetry.maxAttempts })), jobId, taskId, 'runtime retry event');
317
314
  this.detach(this.eventCoordinator.accept({ jobId, reason: 'browser-workspace-runtime-retry' }), jobId, taskId, 'runtime retry reconcile wake');
318
315
  return;
319
316
  }
@@ -10,6 +10,7 @@ import { dependencyReady, schedule, transitionTaskEvent } from './scheduler.js';
10
10
  import { applyTaskRetryAction } from './task-actions.js';
11
11
  import { LaunchFailure, StartAckTimeout } from './adapters.js';
12
12
  import { eventFor } from './events.js';
13
+ import { PreSubmissionRejection } from './worker-rejection.js';
13
14
  import { createPlanRound, materializePlannerChild, pendingPlan } from './planner.js';
14
15
  import { retryPendingPlannerUnblocks } from './planner-runtime.js';
15
16
  import { dispatchPlannerOrchestration, plannerOrchestrationFacts } from './workflow/planner-orchestration-v2.js';
@@ -509,10 +510,10 @@ export class Reconciler extends ReconcilerReviewBase {
509
510
  catch (error) {
510
511
  await startAckSetup?.stop();
511
512
  terminalWait?.cancel();
512
- await this.failLaunch(job.id, task.id, task.executionId, error instanceof Error ? error.message : String(error));
513
+ await this.failLaunch(job.id, task.id, task.executionId, error);
513
514
  return;
514
515
  }
515
- void (execution.launched ?? Promise.resolve()).then(() => Promise.all([recordThreadDelivery(this.store, job.id, task.id, durableExecutionId, workerTask), this.emit(eventFor(job.id, task.id, task.parentTaskId, 'task.process.launched', 'running', `Task ${task.id} process launched`, 'user', { adapter: adapter.id, model: task.routing?.model, pid: execution.pid ?? null, executionId: durableExecutionId }, { agent: 'relay', component: 'agents-relay' }))])).catch(error => this.failLaunch(job.id, task.id, durableExecutionId, error instanceof Error ? error.message : String(error), 'launch'));
516
+ void (execution.launched ?? Promise.resolve()).then(() => Promise.all([recordThreadDelivery(this.store, job.id, task.id, durableExecutionId, workerTask), this.emit(eventFor(job.id, task.id, task.parentTaskId, 'task.process.launched', 'running', `Task ${task.id} process launched`, 'user', { adapter: adapter.id, model: task.routing?.model, pid: execution.pid ?? null, executionId: durableExecutionId }, { agent: 'relay', component: 'agents-relay' }))])).catch(error => this.failLaunch(job.id, task.id, durableExecutionId, error, 'launch'));
516
517
  if (startAckSetup)
517
518
  void startAckSetup.ack.finally(() => { void startAckSetup.stop(); }).catch(() => { });
518
519
  this.live.set(task.id, { jobId: job.id, taskId: task.id, executionId: durableExecutionId, execution, controller, launchBarrier: startedAt, workerStarted: false, processSettled: false });
@@ -529,9 +530,9 @@ export class Reconciler extends ReconcilerReviewBase {
529
530
  }, Math.max(1, task.timeoutMs));
530
531
  this.terminalTimers.set(task.id, terminalTimer);
531
532
  this.detach(execution.promise.then(result => this.runtimeSettled(job.id, task.id, durableExecutionId, result, null), error => error instanceof StartAckTimeout
532
- ? this.failLaunch(job.id, task.id, durableExecutionId, error.message, 'start-ack')
533
- : error instanceof LaunchFailure
534
- ? this.failLaunch(job.id, task.id, durableExecutionId, error.message, 'delivery')
533
+ ? this.failLaunch(job.id, task.id, durableExecutionId, error, 'start-ack')
534
+ : error instanceof LaunchFailure || error instanceof PreSubmissionRejection
535
+ ? this.failLaunch(job.id, task.id, durableExecutionId, error, 'delivery')
535
536
  : this.runtimeSettled(job.id, task.id, durableExecutionId, null, error instanceof Error ? error.message : String(error))), job.id, task.id, 'worker runtime completion');
536
537
  }
537
538
  async applyPlannerTree(jobId, planId, executionId, proposal) {
@@ -9,3 +9,48 @@ export function classifyWorkerRejection(message) {
9
9
  return 'worker_process_failed';
10
10
  }
11
11
  export const reroutableWorkerRejections = new Set(['rate_limit_exceeded', 'model_not_found', 'provider_unavailable']);
12
+ /** Constructed only from a platform response that explicitly says no submission occurred. */
13
+ export class PreSubmissionRejection extends Error {
14
+ classification;
15
+ platformEvidence;
16
+ constructor(classification, platformEvidence) {
17
+ super(platformEvidence);
18
+ this.classification = classification;
19
+ this.platformEvidence = platformEvidence;
20
+ this.name = 'PreSubmissionRejection';
21
+ }
22
+ static fromPlatformResponse(response) {
23
+ if (!response || typeof response !== 'object' || Array.isArray(response))
24
+ return undefined;
25
+ const result = response;
26
+ if (result.status !== 'rejected' || result.submitted !== false || typeof result.code !== 'string' || typeof result.message !== 'string')
27
+ return undefined;
28
+ const classifications = {
29
+ MODEL_UNAVAILABLE: 'model_unavailable',
30
+ MODEL_NOT_FOUND: 'model_unavailable',
31
+ MCP_LOGIN_REQUIRED: 'mcp_login_required',
32
+ PROJECT_SETUP_UNAVAILABLE: 'project_setup_unavailable',
33
+ BROWSER_WORKSPACE_UNAVAILABLE: 'browser_workspace_unavailable',
34
+ };
35
+ const classification = classifications[result.code];
36
+ if (!classification)
37
+ return undefined;
38
+ const platformEvidence = JSON.stringify({ status: result.status, submitted: result.submitted, code: result.code, message: result.message });
39
+ return new PreSubmissionRejection(classification, platformEvidence);
40
+ }
41
+ }
42
+ /** Only positive pre-submission rejection evidence permits another route to launch. */
43
+ export function classifyChatGptPlatformFailure(error, phase) {
44
+ if (error instanceof PreSubmissionRejection)
45
+ return {
46
+ classification: error.classification,
47
+ safeToReroute: true,
48
+ evidence: error.platformEvidence,
49
+ };
50
+ const evidence = error instanceof Error ? error.message : String(error);
51
+ if (phase === 'start-ack')
52
+ return { classification: 'start_ack_timeout', safeToReroute: false, evidence };
53
+ if (phase === 'launch')
54
+ return { classification: 'launch_failure', safeToReroute: false, evidence };
55
+ return { classification: 'outcome_unknown', safeToReroute: false, evidence };
56
+ }
@@ -74,22 +74,19 @@ export function workerCandidates(adapters, registered, snapshot) {
74
74
  });
75
75
  });
76
76
  }
77
- /**
78
- * Worker legality is intentionally narrow. Descriptive capability labels and provider
79
- * quota are facts for decide(), not hard gates. An explicit runtime is retained as a
80
- * compatibility/caller-owned constraint, and an explicitly offline worker is illegal.
81
- */
82
- export function satisfiesWorker(candidate, requirements) {
77
+ export function satisfiesWorker(candidate, requirements, options = {}) {
83
78
  if (candidate.availability === 'offline')
84
79
  return false;
85
80
  if (requirements.runtime && candidate.runtime !== requirements.runtime)
86
81
  return false;
82
+ if (options.strictCapabilities && (requirements.capabilities ?? []).some(capability => !candidate.capabilities.includes(capability)))
83
+ return false;
87
84
  if (candidate.adapter === 'chatgpt' && candidate.browserWorkspaceCapacity?.available === 0)
88
85
  return false;
89
86
  return true;
90
87
  }
91
- export function eligibleWorkerCandidates(candidates, requirements) {
92
- return candidates.filter(candidate => satisfiesWorker(candidate, requirements));
88
+ export function eligibleWorkerCandidates(candidates, requirements, options = {}) {
89
+ return candidates.filter(candidate => satisfiesWorker(candidate, requirements, options));
93
90
  }
94
91
  function deterministicWorker(eligible) {
95
92
  return eligible.find(candidate => candidate.adapter === 'codex') ?? eligible[0];
@@ -40,6 +40,7 @@ export const WORKFLOW_EVENT_NAMES = [
40
40
  'watchdog.failed',
41
41
  'worker.delivery_failed',
42
42
  'worker.start_ack_failed',
43
+ 'worker.platform_failure',
43
44
  ];
44
45
  export const WORKFLOW_FACT_NAMES = [
45
46
  'dependenciesSatisfied',
@@ -59,6 +60,7 @@ export const WORKFLOW_FACT_NAMES = [
59
60
  'implementationComplete',
60
61
  'headAvailable',
61
62
  'mergeCapable',
63
+ 'platformFallbackSafe',
62
64
  ];
63
65
  export const WORKFLOW_MUTATION_NAMES = [
64
66
  'mutation.clearError',
@@ -68,6 +70,8 @@ export const WORKFLOW_MUTATION_NAMES = [
68
70
  'mutation.markLeaseExpired',
69
71
  'mutation.markStartupOrphanedExhausted',
70
72
  'mutation.markPlannerUnavailable',
73
+ 'mutation.preparePlatformFallback',
74
+ 'mutation.quarantinePlatformFailure',
71
75
  ];
72
76
  export const WORKFLOW_EFFECT_NAMES = [
73
77
  'effect.launchTask',
@@ -28,6 +28,7 @@ export const taskLifecycleV2 = load('task-lifecycle', TASK_STATES, {
28
28
  troubleshooterRecovery: boolGuard('troubleshooterRecovery'),
29
29
  retryAvailable: boolGuard('retryAvailable'),
30
30
  autonomousJob: boolGuard('autonomousJob'),
31
+ platformFallbackSafe: boolGuard('platformFallbackSafe'),
31
32
  });
32
33
  export const jobLifecycleV2 = load('job-lifecycle', JOB_STATES, {
33
34
  notMerged: boolGuard('notMerged'),
@@ -34,6 +34,23 @@ export function applyTaskMutation(task, mutation) {
34
34
  task.error = 'Orphaned after relayd restart after maximum attempts';
35
35
  if (mutation === 'mutation.markPlannerUnavailable')
36
36
  task.error = 'No objective planner configured';
37
+ if (mutation === 'mutation.preparePlatformFallback') {
38
+ task.error = null;
39
+ task.failureReason = undefined;
40
+ task.routing = undefined;
41
+ task.workerSelection = undefined;
42
+ task.leaseOwner = null;
43
+ task.leaseExpiresAt = null;
44
+ task.executionId = null;
45
+ }
46
+ if (mutation === 'mutation.quarantinePlatformFailure') {
47
+ task.routing = undefined;
48
+ task.workerSelection = undefined;
49
+ task.leaseOwner = null;
50
+ task.leaseExpiresAt = null;
51
+ task.executionId = null;
52
+ task.failureReason = task.platformFailure?.safeToReroute ? 'worker_platform_fallback_unavailable' : 'worker_outcome_unknown';
53
+ }
37
54
  }
38
55
  export function applyTaskGraphTarget(task, target, timestamp = new Date().toISOString()) {
39
56
  const previous = Date.parse(task.updatedAt), candidate = Date.parse(timestamp);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-relay",
3
- "version": "2.0.139",
3
+ "version": "2.0.140",
4
4
  "description": "Durable async agent jobs coordinated through GitHub pull requests",
5
5
  "repository": {
6
6
  "type": "git",
@@ -18,7 +18,10 @@
18
18
  "dev": "tsx src/cli.ts",
19
19
  "test:publish": "npm run build && node --import ./test/test-store-env.mjs --test --test-concurrency=1 --test-force-exit test/*.test.js",
20
20
  "test:fast": "npm run build && node --import ./test/test-store-env.mjs --test --test-concurrency=2 test/workflow-lifecycle.test.js test/workflow-v2-lifecycle-parity.test.js test/workflow-effects.test.js test/relay-store.test.js test/local-authority.test.js test/local-jobid-routing.test.js test/local-sqlite-jobid.test.js test/no-github.test.js test/scheduled-jobs.test.js test/reconcile-coordinator.test.js test/service-publication.test.js test/github-webhook.test.js",
21
- "test:full": "npm run build && node --import ./test/test-store-env.mjs --test --test-concurrency=1 test/*.test.js",
21
+ "test:full": "npm run build && node --import ./test/test-store-env.mjs --test --test-concurrency=1 test/*.test.js && npm run test:verification:l3",
22
+ "test:verification:l1": "node --test test/verification/harness.test.mjs",
23
+ "test:verification:l3": "npm run build && node --test test/verification/harness.test.mjs test/verification/l3-sqlite.test.mjs test/verification/l3-ingress.test.mjs test/verification/l3-replay.test.mjs",
24
+ "test:verification": "npm run test:verification:l1 && npm run test:verification:l3",
22
25
  "e2e": "node e2e/runner/run.mjs"
23
26
  },
24
27
  "devDependencies": {
@@ -7,6 +7,7 @@ blocked["state: BLOCKED"]
7
7
  succeeded["state: SUCCEEDED"]
8
8
  failed["state: FAILED"]
9
9
  cancelled["state: CANCELLED"]
10
+ platformFallbackSafe["guard: platformFallbackSafe"]
10
11
 
11
12
  depSat["guard: dependenciesSatisfied"]
12
13
  depUnsat["guard: dependenciesUnsatisfied"]
@@ -124,6 +125,13 @@ queued -->|on: effect.launchTask.failed| failed
124
125
  queued -->|on: watchdog.failed| failed
125
126
  queued -->|on: worker.delivery_failed| failed
126
127
  queued -->|on: worker.start_ack_failed| failed
128
+ queued -->|on: worker.platform_failure| platformFallbackSafe
129
+ platformFallbackSafe -->|true| platformFallbackClearFailure
130
+ platformFallbackClearFailure["action: mutation.preparePlatformFallback"]
131
+ platformFallbackClearFailure --> queued
132
+ platformFallbackSafe -->|false| platformFallbackQuarantine
133
+ platformFallbackQuarantine["action: mutation.quarantinePlatformFailure"]
134
+ platformFallbackQuarantine --> blocked
127
135
  queued -->|on: planner.unavailable| plannerUnavailableMark
128
136
  plannerUnavailableMark --> failed
129
137
  queued -->|on: execution.orphaned| orphanRetryAvailable
@@ -150,6 +158,9 @@ running -->|on: task.completed| completeClearError
150
158
  completeClearError --> completeClearReason
151
159
  completeClearReason --> succeeded
152
160
  running -->|on: task.failed| failed
161
+ running -->|on: worker.platform_failure| runningPlatformFailureQuarantine
162
+ runningPlatformFailureQuarantine["action: mutation.quarantinePlatformFailure"]
163
+ runningPlatformFailureQuarantine --> blocked
153
164
  running -->|on: watchdog.failed| failed
154
165
  running -->|on: planner.failed| plannerRetryAvailable
155
166
  running -->|on: execution.orphaned| orphanRetryAvailable