@blindmarket/sdk 0.6.1 → 0.6.4

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.
@@ -1,11 +1,11 @@
1
1
  import { ethers } from 'ethers';
2
- import { BlindMarket } from '../index.js';
2
+ import { BlindMarket, ApiError } from '../index.js';
3
3
  import { eciesDecrypt, aesDecrypt, derivePublicKey } from '../crypto/index.js';
4
4
  /**
5
5
  * Chains this runtime's CODE can sign submitEvidence on. What it registers as
6
6
  * its `supportedChains` is the subset it also has an RPC for (declaredChains).
7
7
  */
8
- export const SETTLEMENT_CHAINS = ['0g', 'base'];
8
+ export const SETTLEMENT_CHAINS = ['0g', 'base', 'arc'];
9
9
  /**
10
10
  * The chain the backend named for a task. Missing means 0G (backends older
11
11
  * than the field). Any other value throws: signing it on the 0G RPC would
@@ -16,14 +16,13 @@ function settlementChain(taskId, reported) {
16
16
  return '0g';
17
17
  const known = SETTLEMENT_CHAINS.find((c) => c === reported);
18
18
  if (!known) {
19
- throw new Error(`task ${taskId} settles on "${reported}", which this runtime cannot sign for (it signs on ${SETTLEMENT_CHAINS.join(' and ')}) — update @blindmarket/sdk`);
19
+ throw new Error(`task ${taskId} settles on "${reported}", which this runtime cannot sign for (it signs on ${SETTLEMENT_CHAINS.join(', ')}) — update @blindmarket/sdk`);
20
20
  }
21
21
  return known;
22
22
  }
23
23
  /**
24
24
  * The RPC this runtime signs on for `chain`. `rpcUrls` is per chain; the
25
- * single `rpcUrl` is the 0G RPC (its default is 0G testnet) and never stands
26
- * in for another chain: a Base submitEvidence sent through it is rejected by
25
+ * single `rpcUrl` is the 0G RPC and never stands in for another chain: a Base submitEvidence sent through it is rejected by
27
26
  * ethers' chainId pin — after the handler has already run.
28
27
  */
29
28
  function rpcFor(config, chain) {
@@ -34,8 +33,39 @@ const DEFAULTS = {
34
33
  browseIntervalMs: 15_000,
35
34
  watchIntervalMs: 5_000,
36
35
  maxConcurrentTasks: 3,
37
- rpcUrl: 'https://evmrpc-testnet.0g.ai',
36
+ wrapTimeoutMs: 600_000,
37
+ assignmentPendingTimeoutMs: 180_000,
38
38
  };
39
+ /** First back-off after an /accept the backend released or that never confirmed; doubles per failure. */
40
+ const RELEASED_BACKOFF_MS = 30_000;
41
+ const MAX_BACKOFF_MS = 3_600_000;
42
+ const MAX_WRAP_BACKOFF_MS = 86_400_000;
43
+ /** Re-accept rounds for a task that may still be held for this executor before it is dropped. */
44
+ const MAX_REACCEPT_ROUNDS = 6;
45
+ /**
46
+ * NEEDS_WRAP messages after which waiting is pointless: the platform cannot
47
+ * re-wrap (custody key rotated — only the poster's own client still can), or
48
+ * this executor has no public key on record. Newer backends say so in
49
+ * `error.reason` (CUSTODY_ROTATED / NO_PUBLIC_KEY); the message match covers
50
+ * backends that send only the NEEDS_WRAP code.
51
+ */
52
+ const WRAP_IMPOSSIBLE_REASONS = new Set(['CUSTODY_ROTATED', 'NO_PUBLIC_KEY']);
53
+ const WRAP_IMPOSSIBLE = /rotated custody key|cannot re-wrap|no public key/i;
54
+ /** /accept was abandoned; `held` = the backend may still hold the task for this executor. */
55
+ class AcceptAbandoned extends Error {
56
+ cause;
57
+ held;
58
+ constructor(cause, held) {
59
+ super(cause instanceof Error ? cause.message : String(cause));
60
+ this.cause = cause;
61
+ this.held = held;
62
+ }
63
+ }
64
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
65
+ /** base, 2·base, 4·base … capped. `n` is 1 for the first failure. */
66
+ function backoff(base, n, cap) {
67
+ return Math.min(base * 2 ** Math.max(0, n - 1), cap);
68
+ }
39
69
  // ── WorkerRuntime ───────────────────────────────────────────────────────────
40
70
  export class WorkerRuntime {
41
71
  bb;
@@ -45,8 +75,9 @@ export class WorkerRuntime {
45
75
  running = false;
46
76
  paused = false;
47
77
  browseTimer;
48
- watchTimers = new Map();
49
78
  executions = new Map();
79
+ retries = new Map();
80
+ retryTimers = new Set();
50
81
  listeners = new Set();
51
82
  constructor(config) {
52
83
  this.config = { ...DEFAULTS, ...config };
@@ -63,10 +94,10 @@ export class WorkerRuntime {
63
94
  return [...this.executions.values()];
64
95
  }
65
96
  /**
66
- * The chains this runtime declares to the backend: those its code can sign
67
- * for AND it has an RPC for. Declaring a chain with no RPC made the backend
68
- * offer tasks the runtime accepted and then could not settle, stranding
69
- * them until the poster's deadline.
97
+ * The chains this runtime declares to the backend and claims tasks on:
98
+ * those its code can sign for AND it has an RPC for. Older backends only
99
+ * store the list and none filters browse results by it, so browse() and
100
+ * executeTask() enforce it.
70
101
  */
71
102
  get declaredChains() {
72
103
  return SETTLEMENT_CHAINS.filter((chain) => !!rpcFor(this.config, chain));
@@ -92,40 +123,62 @@ export class WorkerRuntime {
92
123
  async start() {
93
124
  if (this.running)
94
125
  return this.profile;
126
+ // Refuse, before any request, a configuration that can only strand tasks.
127
+ const key = this.config.privateKey ?? this.config.existingPrivateKey;
128
+ if (!key) {
129
+ throw new Error('[WorkerRuntime] no executor key. Pass `privateKey`: the private key of the wallet that owns `apiKey` ' +
130
+ '(or `existingPrivateKey` to restore without re-registering). The backend assigns accepted tasks on-chain to ' +
131
+ "the API key's owner, and only that wallet can sign submitEvidence — a keyless runtime would overwrite the " +
132
+ "owner's registered public key and strand every task it accepted. To only look at tasks, call " +
133
+ 'BlindMarket.browseA2ATasks() directly.');
134
+ }
135
+ if (this.declaredChains.length === 0) {
136
+ throw new Error('[WorkerRuntime] no RPC configured. Set `rpcUrls.arc` (where production posts new tasks), `rpcUrls.base` and/or `rpcUrl` (0G) to the network the backend at ' +
137
+ '`apiBase` settles on. There is no default: submitEvidence is signed on this RPC after the task is already ' +
138
+ 'assigned, so a guessed network strands it.');
139
+ }
95
140
  // "No tasks arrive" looks the same as "no work available", so say which
96
- // chains this runtime will be offered tasks on, and what is missing.
141
+ // chains this runtime claims tasks on, and what is missing.
97
142
  const undeclared = SETTLEMENT_CHAINS.filter((c) => !this.declaredChains.includes(c));
98
143
  if (undeclared.length > 0) {
99
144
  console.warn(`[WorkerRuntime] declaring chains: ${this.declaredChains.join(', ') || 'none'}. ` +
100
- `No RPC for ${undeclared.join(', ')} — set ${undeclared.map((c) => `rpcUrls.${c}`).join(', ')} to be offered those tasks ` +
101
- `(production posts new tasks on Base).`);
145
+ `No RPC for ${undeclared.join(', ')} — tasks on ${undeclared.length > 1 ? 'those chains' : 'that chain'} are skipped; ` +
146
+ `set ${undeclared.map((c) => `rpcUrls.${c}`).join(', ')} to claim them ` +
147
+ `(production posts new tasks on Arc).`);
102
148
  }
103
149
  // 1. Register or restore executor
104
- if (this.config.existingPrivateKey && this.config.existingAddress && this.config.existingPublicKey) {
105
- this.wallet = {
106
- address: this.config.existingAddress,
107
- privateKey: this.config.existingPrivateKey,
108
- publicKey: this.config.existingPublicKey,
109
- };
110
- // Fetch existing profile — backend identifies by API key
111
- const result = await this.bb.getExecutorProfile();
112
- this.profile = await this.declareSupportedChains(result.agent);
113
- }
114
- else {
150
+ if (this.config.privateKey) {
151
+ // The API key owner's own key: createAgent() checks it against the API
152
+ // key's owner BEFORE registering (no side effects on a mismatch), then
153
+ // registers its uncompressed public key. Registration is an upsert that
154
+ // keeps reputation, so this is safe on every start.
115
155
  const result = await this.bb.createAgent({
156
+ privateKey: this.config.privateKey,
116
157
  displayName: this.config.displayName,
117
158
  capabilities: this.config.capabilities,
118
159
  minReward: this.config.minReward,
119
160
  preferredCapabilities: this.config.preferredCapabilities,
120
161
  supportedChains: this.declaredChains,
121
162
  });
122
- this.wallet = {
123
- address: result.wallet.address,
124
- privateKey: result.wallet.privateKey,
125
- publicKey: result.wallet.publicKey,
126
- };
163
+ this.wallet = result.wallet;
127
164
  this.profile = result.executor;
128
165
  }
166
+ else {
167
+ const signer = new ethers.Wallet(this.config.existingPrivateKey);
168
+ if (this.config.existingAddress && this.config.existingAddress.toLowerCase() !== signer.address.toLowerCase()) {
169
+ throw new Error(`[WorkerRuntime] existingAddress ${this.config.existingAddress} is not existingPrivateKey's address (${signer.address})`);
170
+ }
171
+ // Fetch existing profile — backend identifies by API key
172
+ const result = await this.bb.getExecutorProfile();
173
+ // Checked before declareSupportedChains() can register anything: tasks
174
+ // are assigned to the profile's address, which must be this signer.
175
+ if (result.agent.address.toLowerCase() !== signer.address.toLowerCase()) {
176
+ throw new Error(`[WorkerRuntime] the API key resolves to executor ${result.agent.address} but existingPrivateKey belongs to ` +
177
+ `${signer.address}; it could accept tasks but never sign submitEvidence. Use the owner wallet's key, or an API key minted by this wallet.`);
178
+ }
179
+ this.wallet = { address: signer.address, privateKey: signer.privateKey, publicKey: signer.signingKey.publicKey };
180
+ this.profile = await this.declareSupportedChains(result.agent);
181
+ }
129
182
  this.emit({ type: 'registered', profile: this.profile });
130
183
  // 2. Start the browse loop
131
184
  this.running = true;
@@ -139,9 +192,11 @@ export class WorkerRuntime {
139
192
  * chain this runtime has no RPC for — it would be offered, accept and
140
193
  * strand those tasks. A stored list that is a SUBSET of what the runtime
141
194
  * can settle is left alone: an operator who registered ['base'] through
142
- * the MCP or PATCH meant it. The backend only offers an executor
143
- * tasks on the chains it declared, and a restore never registers otherwise,
144
- * so an executor first registered by an older SDK would keep its old list.
195
+ * the MCP or PATCH meant it. Older backends only store the list (newer ones
196
+ * also filter offers and /accept by it); this runtime's own browse filter
197
+ * uses `declaredChains`, not the stored list — and a restore never
198
+ * registers otherwise, so an executor first registered by an older SDK
199
+ * would keep its old list.
145
200
  *
146
201
  * A /profile response with no `supportedChains` key comes from a backend
147
202
  * that predates the field. That backend would drop the field anyway, and
@@ -206,9 +261,9 @@ export class WorkerRuntime {
206
261
  clearInterval(this.browseTimer);
207
262
  this.browseTimer = undefined;
208
263
  }
209
- for (const t of this.watchTimers.values())
210
- clearInterval(t);
211
- this.watchTimers.clear();
264
+ for (const t of this.retryTimers)
265
+ clearTimeout(t);
266
+ this.retryTimers.clear();
212
267
  this.emit({ type: 'stopped' });
213
268
  }
214
269
  pause() {
@@ -235,75 +290,250 @@ export class WorkerRuntime {
235
290
  if (this.paused)
236
291
  return;
237
292
  try {
238
- const active = this.activeExecutions;
239
- const inFlight = active.filter(e => e.status === 'bidding' || e.status === 'working').length;
240
- if (inFlight >= this.config.maxConcurrentTasks)
293
+ if (this.inFlight() >= this.config.maxConcurrentTasks)
241
294
  return;
242
295
  const result = await this.bb.browseA2ATasks({
243
296
  capabilities: this.config.capabilities,
244
297
  });
245
298
  this.emit({ type: 'browse_done', found: result.tasks.length });
246
- for (const task of result.tasks) {
247
- if (this.executions.has(task.taskId))
299
+ // GET /a2a/tasks serves { meta, state } entries — id and status live on
300
+ // `state`. Nobody "assigns" a task: the executor claims it by calling
301
+ // /accept itself (which also assigns it on-chain), so there is no
302
+ // 'assigned' status to wait for.
303
+ const listed = new Set();
304
+ for (const entry of result.tasks) {
305
+ const state = entry.state;
306
+ const taskId = state?.taskId ?? entry.meta?.taskId;
307
+ if (!taskId)
248
308
  continue;
249
- if (task.status !== 'open' && task.status !== 'bidding')
309
+ listed.add(taskId);
310
+ if (state.status !== 'open')
250
311
  continue;
251
- if (task.executorAddress && task.executorAddress !== this.wallet?.address)
312
+ // An accept assigns on-chain and cannot be released, so never claim a
313
+ // task browse already says is on a chain this runtime did not declare.
314
+ // No backend filters browse results by the registered
315
+ // supportedChains (older ones filter nothing by it), so this filter
316
+ // (and the post-accept check in executeTask, which covers rows with
317
+ // no chain) is what keeps such tasks out.
318
+ if (entry.meta?.chain && !this.declaredChains.includes(entry.meta.chain))
252
319
  continue;
253
- this.executions.set(task.taskId, {
254
- taskId: task.taskId,
255
- status: 'bidding',
256
- task,
257
- startedAt: Date.now(),
258
- });
259
- this.emit({ type: 'task_found', taskId: task.taskId, task });
260
- await this.bb.bidOnTask(task.taskId);
261
- this.emit({ type: 'task_bidded', taskId: task.taskId });
262
- this.watchForAssignment(task.taskId);
320
+ this.claim(taskId, state, entry.meta);
321
+ }
322
+ // Tasks that may still be held for this executor are not in the open
323
+ // listing: re-try their /accept here once their back-off has passed.
324
+ for (const [taskId, retry] of this.retries) {
325
+ if (retry.reaccept)
326
+ this.claim(taskId, retry.reaccept.state, retry.reaccept.meta);
327
+ // Anything else that left the listing is gone (taken, cancelled,
328
+ // expired): drop its bookkeeping so the map stays bounded.
329
+ else if (!listed.has(taskId))
330
+ this.retries.delete(taskId);
263
331
  }
264
332
  }
265
333
  catch (err) {
266
334
  this.emit({ type: 'error', error: `Browse failed: ${err}` });
267
335
  }
268
336
  }
269
- // ── Assignment watching ─────────────────────────────────────────────────
270
- watchForAssignment(taskId) {
271
- if (!this.running)
272
- return;
273
- const ms = this.config.watchIntervalMs;
274
- const timer = setInterval(async () => {
275
- if (!this.running || this.paused)
276
- return;
337
+ /** Executions holding a concurrency slot. A task waiting for a wrap or backing off holds none. */
338
+ inFlight() {
339
+ let n = 0;
340
+ for (const e of this.executions.values()) {
341
+ if (e.status === 'bidding' || e.status === 'assigned' || e.status === 'working')
342
+ n++;
343
+ }
344
+ return n;
345
+ }
346
+ /**
347
+ * Start executing `taskId` if it is not running, not backing off, and a slot
348
+ * is free. `dueAt`: the time a scheduled re-try counts as running at.
349
+ */
350
+ claim(taskId, state, meta, dueAt = Date.now()) {
351
+ if (this.executions.has(taskId))
352
+ return false;
353
+ const retry = this.retries.get(taskId);
354
+ if (retry && retry.notBefore > dueAt)
355
+ return false;
356
+ if (this.inFlight() >= this.config.maxConcurrentTasks)
357
+ return false;
358
+ this.executions.set(taskId, { taskId, status: 'bidding', task: state, startedAt: Date.now() });
359
+ // Announce a task once, not on every re-try.
360
+ if (!retry)
361
+ this.emit({ type: 'task_found', taskId, task: state });
362
+ void this.executeTask(taskId, state, meta);
363
+ return true;
364
+ }
365
+ retryState(taskId) {
366
+ let retry = this.retries.get(taskId);
367
+ if (!retry) {
368
+ retry = { notBefore: 0, failures: 0, wrapTimeouts: 0, bidded: false };
369
+ this.retries.set(taskId, retry);
370
+ }
371
+ return retry;
372
+ }
373
+ // ── Accept ──────────────────────────────────────────────────────────────
374
+ /**
375
+ * POST /accept, re-trying while the backend says the claim is still ours:
376
+ * 503 ASSIGNMENT_PENDING means the assign tx is broadcast but unconfirmed
377
+ * and the task stays `accepted` for this executor until a retry confirms it
378
+ * (or the backend's sweep releases it). A 503 SETTLEMENT_FAILED seen AFTER a
379
+ * pending answer is the idempotent re-check failing, not a release, so it is
380
+ * re-tried too. Bounded by assignmentPendingTimeoutMs, then AcceptAbandoned
381
+ * with `held: true`. Every other error is thrown as it came.
382
+ */
383
+ async acceptUntilAssigned(taskId) {
384
+ const giveUpAt = Date.now() + this.config.assignmentPendingTimeoutMs;
385
+ let delay = Math.min(2_000, this.config.watchIntervalMs);
386
+ let pending = false;
387
+ for (;;) {
277
388
  try {
278
- const detail = await this.bb.getTask(taskId);
279
- const a2a = detail.a2aState;
280
- if (!a2a)
281
- return;
282
- if (a2a.status === 'assigned') {
283
- clearInterval(timer);
284
- this.watchTimers.delete(taskId);
285
- this.emit({ type: 'task_assigned', taskId });
286
- await this.executeTask(taskId, a2a);
389
+ return await this.bb.acceptTask(taskId);
390
+ }
391
+ catch (err) {
392
+ const code = err instanceof ApiError ? err.code : undefined;
393
+ if (code === 'ASSIGNMENT_PENDING')
394
+ pending = true;
395
+ else if (!(pending && code === 'SETTLEMENT_FAILED'))
396
+ throw err;
397
+ if (!this.running || Date.now() + delay > giveUpAt)
398
+ throw new AcceptAbandoned(err, true);
399
+ await sleep(delay);
400
+ delay = Math.min(delay * 2, 15_000);
401
+ }
402
+ }
403
+ }
404
+ /**
405
+ * /accept did not hand over the task. Release the slot and decide when (if
406
+ * ever) the task is touched again. Returns the message for `task_failed`.
407
+ */
408
+ async onAcceptFailed(taskId, a2a, meta, err) {
409
+ this.executions.delete(taskId);
410
+ const now = Date.now();
411
+ const cause = err instanceof AcceptAbandoned ? err.cause : err;
412
+ const api = cause instanceof ApiError ? cause : undefined;
413
+ // 403 NEEDS_WRAP: the brief key is not wrapped to our pubkey. Bid (the
414
+ // poster wraps to bidders) and wait WITHOUT a slot: the task is re-tried
415
+ // every watchIntervalMs until wrapTimeoutMs, then backed off.
416
+ if (api?.code === 'NEEDS_WRAP') {
417
+ const retry = this.retryState(taskId);
418
+ if (!retry.bidded) {
419
+ try {
420
+ await this.bb.bidOnTask(taskId);
421
+ retry.bidded = true;
422
+ this.emit({ type: 'task_bidded', taskId });
423
+ }
424
+ catch (bidErr) {
425
+ retry.failures++;
426
+ retry.notBefore = now + backoff(RELEASED_BACKOFF_MS, retry.failures, MAX_BACKOFF_MS);
427
+ return `not accepted: NEEDS_WRAP, and the bid failed: ${bidErr}`;
287
428
  }
288
429
  }
289
- catch {
290
- // Retry next tick
430
+ const reason = api.body?.error?.reason;
431
+ const impossible = typeof reason === 'string'
432
+ ? WRAP_IMPOSSIBLE_REASONS.has(reason)
433
+ : WRAP_IMPOSSIBLE.test(api.message);
434
+ retry.wrapSince ??= now;
435
+ if (!impossible && now - retry.wrapSince < this.config.wrapTimeoutMs) {
436
+ retry.notBefore = now + this.config.watchIntervalMs;
437
+ this.scheduleRetry(taskId, a2a, meta, this.config.watchIntervalMs);
438
+ return 'not accepted yet: NEEDS_WRAP — bid placed, waiting for the poster to wrap the brief key';
291
439
  }
440
+ // Timed out, or the backend says the platform can never wrap it: only
441
+ // the poster's own client still can, so look again much later.
442
+ retry.wrapTimeouts++;
443
+ retry.wrapSince = undefined;
444
+ retry.bidded = false; // bid again when the back-off ends
445
+ const wait = backoff(this.config.wrapTimeoutMs, retry.wrapTimeouts, MAX_WRAP_BACKOFF_MS);
446
+ retry.notBefore = now + wait;
447
+ return impossible
448
+ ? `not accepted: ${api.message} Skipping for ${Math.round(wait / 60_000)} min.`
449
+ : `not accepted: no wrapped key after ${Math.round(this.config.wrapTimeoutMs / 1000)}s; skipping for ${Math.round(wait / 60_000)} min`;
450
+ }
451
+ // Lost the race / offer held by another agent / gone: nothing was claimed.
452
+ // A still-open task (OFFER_HELD, ACCEPT_LOCKED) is re-tried by a later browse.
453
+ if (api?.status === 409) {
454
+ this.retries.delete(taskId);
455
+ return `not accepted: ${api.code ?? api.message}`;
456
+ }
457
+ // Refused for a reason a retry cannot change (SELF_ACCEPT, IS_VERIFIER,
458
+ // NOT_REGISTERED, NOT_TARGET_EXECUTOR, 404 …): nothing was claimed. Keep
459
+ // the task out of the way for as long as it stays listed.
460
+ if (api && api.status < 500 && api.status !== 408 && api.status !== 429) {
461
+ const retry = this.retryState(taskId);
462
+ retry.reaccept = undefined;
463
+ retry.notBefore = now + MAX_WRAP_BACKOFF_MS;
464
+ return `not accepted: ${api.code ?? api.message}`;
465
+ }
466
+ // The backend released the task back to open (503 REWRAP_FAILED, or a 503
467
+ // SETTLEMENT_FAILED on a fresh accept): a later browse may claim it again,
468
+ // after a per-task back-off so a persistent failure does not hot-loop.
469
+ const retry = this.retryState(taskId);
470
+ retry.failures++;
471
+ // The backend says "released" in the message only when it did re-open the
472
+ // task (its release is compare-and-set and can fail; SETTLEMENT_FAILED from
473
+ // the idempotent re-check never releases). There is no separate code.
474
+ const released = !(err instanceof AcceptAbandoned) &&
475
+ (api?.code === 'REWRAP_FAILED' || api?.code === 'SETTLEMENT_FAILED') &&
476
+ /released/i.test(api.message);
477
+ if (released) {
478
+ retry.reaccept = undefined;
479
+ retry.notBefore = now + backoff(RELEASED_BACKOFF_MS, retry.failures, MAX_BACKOFF_MS);
480
+ return `not accepted: ${api.code} — the backend released the task; re-trying after a back-off`;
481
+ }
482
+ // ASSIGNMENT_PENDING that never confirmed, a REWRAP/SETTLEMENT failure the
483
+ // backend did not release, an unknown 5xx, a rate limit or a network error: the task MAY be held for (or already assigned to) this executor,
484
+ // and then it is not in the open listing. /accept is idempotent for the
485
+ // recorded executor, so browse re-tries it directly, with back-off, a
486
+ // bounded number of times.
487
+ const rounds = (retry.reaccept?.rounds ?? 0) + 1;
488
+ if (rounds > MAX_REACCEPT_ROUNDS) {
489
+ this.retries.delete(taskId);
490
+ return `accept never confirmed after ${MAX_REACCEPT_ROUNDS} rounds (${api?.code ?? cause}); giving up. If the assignment landed on-chain the task is in getExecutions().`;
491
+ }
492
+ retry.reaccept = { state: a2a, meta, rounds };
493
+ retry.notBefore = now + backoff(RELEASED_BACKOFF_MS, retry.failures, MAX_BACKOFF_MS);
494
+ return `accept not confirmed (${api?.code ?? cause}); the task may still be held for this executor — re-trying /accept after a back-off`;
495
+ }
496
+ /** Re-try one task sooner than the next browse tick (NEEDS_WRAP wait). */
497
+ scheduleRetry(taskId, a2a, meta, ms) {
498
+ if (!this.running)
499
+ return;
500
+ // The back-off this timer ends. A timer can fire before Date.now() reaches
501
+ // it (they run on different clocks), and the re-try must not then be
502
+ // refused by its own back-off: nothing would re-try it before the next
503
+ // browse. A back-off set after this one still holds.
504
+ const due = this.retries.get(taskId)?.notBefore ?? 0;
505
+ const timer = setTimeout(() => {
506
+ this.retryTimers.delete(timer);
507
+ // No free slot / paused: the next browse picks it up instead.
508
+ if (this.running && !this.paused)
509
+ this.claim(taskId, a2a, meta, Math.max(due, Date.now()));
292
510
  }, ms);
293
- this.watchTimers.set(taskId, timer);
511
+ this.retryTimers.add(timer);
294
512
  }
295
513
  // ── Task execution ──────────────────────────────────────────────────────
296
- async executeTask(taskId, a2a) {
514
+ async executeTask(taskId, a2a, meta) {
297
515
  const exec = this.executions.get(taskId);
298
516
  if (!exec)
299
517
  return;
300
518
  try {
301
- exec.status = 'assigned';
302
519
  // Accept task — get the rootHash + this executor's ECIES-wrapped AES
303
520
  // key. wrappedKey is a single hex string (this caller's slice), not a
304
521
  // Record — see acceptTask()'s doc comment in ../index.ts.
305
- const acceptResult = await this.bb.acceptTask(taskId);
522
+ let acceptResult;
523
+ try {
524
+ acceptResult = await this.acceptUntilAssigned(taskId);
525
+ }
526
+ catch (err) {
527
+ // Never leave a task that was not handed over in `executions`: it
528
+ // would be skipped by every later browse and never executed.
529
+ const error = await this.onAcceptFailed(taskId, a2a, meta, err);
530
+ this.emit({ type: 'task_failed', taskId, error });
531
+ return;
532
+ }
533
+ this.retries.delete(taskId);
534
+ exec.status = 'assigned';
306
535
  exec.task = a2a;
536
+ this.emit({ type: 'task_assigned', taskId });
307
537
  this.emit({ type: 'task_accepted', taskId });
308
538
  // Fail before running the handler if this runtime can't settle the task:
309
539
  // an unknown chain, or one it has no RPC for (the send would fail only
@@ -338,38 +568,23 @@ export class WorkerRuntime {
338
568
  const result = await this.config.executeTask({
339
569
  taskId,
340
570
  task: a2a,
571
+ meta,
341
572
  instructions,
342
573
  });
343
574
  exec.status = 'submitted';
344
575
  this.emit({ type: 'task_executed', taskId });
345
- // Submit result — the contract requires the assigned worker to sign
346
- // submitEvidence personally (onlyWorker), so the backend hands back an
347
- // unsigned tx instead of broadcasting it for us.
348
- const submitResult = await this.bb.submitResult(taskId, result);
349
- // Sign + broadcast submitEvidence with the runtime's own wallet, wait
350
- // for confirmation, then tell the backend to proceed with
351
- // verification. Without this the on-chain task never leaves
352
- // 'Assigned' and completeVerification always reverts — the task only
353
- // LOOKS complete.
354
- if (submitResult.unsignedSubmitEvidence) {
355
- // Pick the RPC for the chain the backend says holds this task. This
356
- // used to be a single 0G provider, so a Base submitEvidence was
357
- // broadcast onto 0G. The tx now also carries chainId, so a wrong RPC
358
- // fails loudly at ethers instead of landing on the wrong network.
359
- const chain = settlementChain(taskId, submitResult.chain);
360
- // rpcUrls per chain; rpcUrl is the 0G RPC only (rpcFor). The chainId
361
- // pin still rejects a genuine mismatch at ethers before anything is
362
- // broadcast.
363
- const rpc = rpcFor(this.config, chain);
364
- if (!rpc) {
365
- throw new Error(`task ${taskId} is escrowed on ${chain} but no RPC is configured for it — set rpcUrls.${chain} in the WorkerRuntime config`);
366
- }
367
- const provider = new ethers.JsonRpcProvider(rpc);
368
- const signer = new ethers.Wallet(this.wallet.privateKey, provider);
369
- const tx = await signer.sendTransaction(submitResult.unsignedSubmitEvidence);
370
- await tx.wait();
371
- }
372
- const finalizeResult = await this.bb.finalize(taskId);
576
+ // Submit → sign + broadcast submitEvidence on the chain /submit names
577
+ // (the contract's onlyWorker means the backend can only hand back an
578
+ // unsigned tx) → finalize, healing a stranded 'submitted' state via
579
+ // /rebroadcast. Without the broadcast the on-chain task never leaves
580
+ // 'Assigned' and the task only LOOKS complete. deliverResult() signs
581
+ // only on a chain named here: rpcUrls per chain, rpcUrl for 0G only, so
582
+ // an unknown chain refuses before anything is sent, and the chainId pin
583
+ // still rejects a genuine mismatch at ethers.
584
+ const { submitTxHash: _submitTxHash, ...finalizeResult } = await this.bb.deliverResult(taskId, result, {
585
+ privateKey: this.wallet.privateKey,
586
+ rpcUrls: Object.fromEntries(SETTLEMENT_CHAINS.map((c) => [c, rpcFor(this.config, c)])),
587
+ });
373
588
  exec.status = 'completed';
374
589
  this.emit({ type: 'task_submitted', taskId, result });
375
590
  this.emit({ type: 'task_finalized', taskId, finalize: finalizeResult });