@elisym/cli 0.30.0 → 0.32.0
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/index.js +1330 -185
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -2,19 +2,19 @@
|
|
|
2
2
|
import { ReadableStream } from 'node:stream/web';
|
|
3
3
|
import { readFileSync, existsSync, readdirSync, statSync, renameSync, chmodSync, mkdirSync, writeFileSync, appendFileSync, unlinkSync, rmdirSync, realpathSync } from 'node:fs';
|
|
4
4
|
import { dirname, join, resolve, basename, extname, relative, sep } from 'node:path';
|
|
5
|
-
import { SolanaPaymentStrategy, validateAgentName, RELAYS, ElisymIdentity, splAssetsForNetwork, formatSol, formatAssetAmount, signerFromSecretKeyBase58, resolveUsdcAsset, ElisymClient, KIND_EXTERNAL_IDENTITIES, POLICY_D_TAG_PREFIX, KIND_LONG_FORM_ARTICLE, jobRequestKind, DEFAULT_KIND_OFFSET, toDTag,
|
|
5
|
+
import { DEFAULTS, SolanaPaymentStrategy, validateAgentName, RELAYS, ElisymIdentity, splAssetsForNetwork, formatSol, formatAssetAmount, signerFromSecretKeyBase58, resolveUsdcAsset, ElisymClient, KIND_EXTERNAL_IDENTITIES, POLICY_D_TAG_PREFIX, KIND_LONG_FORM_ARTICLE, deleteControlCharacters, jobRequestKind, DEFAULT_KIND_OFFSET, toDTag, createBlossomTransport, generateSolanaWallet, verifyAgentIdentities, getProtocolConfig, getProtocolProgramId, calculateProtocolFee, LIMITS, makeCensor, DEFAULT_REDACT_PATHS, flattenForComparison, POLICY_T_TAG, SESSION_ID_REGEX, createSlidingWindowLimiter, excerptUntrusted, isScriptBillingExhaustedError, excerptUntrustedTail, scriptOutput, isScriptExecutionError, utf8ByteLength, readAcceptedTransports, resolveDelegationAsset, MAX_PROOF_TTL_SECS, PROOF_CLOCK_SKEW_SECS, verifyDelegationAuthProof, deriveOwnerDelegationAta, getDelegation, buildDelegatedTransfer, buildSignedPull, sendConfirmToTerminal, parseDelegatedPayment, confirmPullToTerminal, isDefinitelyUnpaid, decodeJobPayload, isLlmHealthError, BoundedSet, KIND_JOB_FEEDBACK, encodeSecretKeyBase58, GITHUB_USERNAME_REGEX, X_USERNAME_REGEX, normalizeNip05Identifier, splitNip05Identifier, clipToCodeUnits, PROVIDER_REFUSED_PREFIX, PROVIDER_FAILED_MESSAGE, NATIVE_SOL, GIST_ID_REGEX, TWEET_ID_REGEX } from '@elisym/sdk';
|
|
6
6
|
import { ElisymYamlSchema, resolveInHome, resolveInProject, createAgentDir, writeYamlInitial, writeExampleSkillTemplate, writeSecrets, listAgents, loadAgent, writeYaml, agentPaths, readMediaCache, loadPoliciesFromDir, ensureGitignoreHasIrohEntry, ensureGitignoreHasX402Entries, ensureGitignoreHasSessionsEntry, ensureGitignoreHasDelegationNoncesEntry, readAgentPublic, lookupCachedUrl, newCacheEntry, writeMediaCache } from '@elisym/sdk/agent-store';
|
|
7
7
|
import { isAddress, createSolanaRpc, address, signature } from '@solana/kit';
|
|
8
8
|
import { generateSecretKey, getPublicKey, nip19, verifyEvent } from 'nostr-tools';
|
|
9
9
|
import YAML2 from 'yaml';
|
|
10
10
|
import { Command } from 'commander';
|
|
11
|
-
import { mkdir, writeFile,
|
|
12
|
-
import { parseSkillMd, validateSkillFrontmatter, DEFAULT_X402_MAX_INPUT_BYTES, resolveInsidePathReal, DEFAULT_SCRIPT_TIMEOUT_MS, OnchainCallSkill, DynamicScriptSkill as DynamicScriptSkill$1, StaticScriptSkill as StaticScriptSkill$1, StaticFileSkill as StaticFileSkill$1, ScriptSkill as ScriptSkill$1 } from '@elisym/sdk/skills';
|
|
11
|
+
import { mkdir, writeFile, mkdtemp, rm, readFile, rename, stat } from 'node:fs/promises';
|
|
12
|
+
import { parseSkillMd, validateSkillFrontmatter, isHostScratchError, isScriptRefusalError, startsWithRefusalHint, HostScratchError, DEFAULT_X402_MAX_INPUT_BYTES, resolveInsidePathReal, DEFAULT_SCRIPT_TIMEOUT_MS, OnchainCallSkill, DynamicScriptSkill as DynamicScriptSkill$1, StaticScriptSkill as StaticScriptSkill$1, StaticFileSkill as StaticFileSkill$1, ScriptSkill as ScriptSkill$1 } from '@elisym/sdk/skills';
|
|
13
13
|
import chalk from 'chalk';
|
|
14
14
|
import Decimal from 'decimal.js-light';
|
|
15
15
|
import { decodePaymentRequiredHeader } from '@x402/core/http';
|
|
16
16
|
import { createHash } from 'node:crypto';
|
|
17
|
-
import { LlmHealthMonitor, startLlmRecovery, createFreeLlmLimiterSet,
|
|
17
|
+
import { LlmHealthMonitor, startLlmRecovery, createFreeLlmLimiterSet, FREE_LLM_GLOBAL_KEY, freeLlmCustomerKey } from '@elisym/sdk/llm-health';
|
|
18
18
|
import { createIrohTransport } from '@elisym/sdk/node';
|
|
19
19
|
import { lookup } from 'node:dns/promises';
|
|
20
20
|
import { Socket } from 'node:net';
|
|
@@ -1499,6 +1499,8 @@ var init_init = __esm({
|
|
|
1499
1499
|
var MAX_CONCURRENT_JOBS = 10;
|
|
1500
1500
|
var RECOVERY_MAX_RETRIES = 5;
|
|
1501
1501
|
var RECOVERY_INTERVAL_SECS = 60;
|
|
1502
|
+
var MAX_PAID_AGE_MS = 24 * 60 * 60 * 1e3;
|
|
1503
|
+
var LEDGER_RETENTION_MS = 30 * 24 * 60 * 60 * 1e3;
|
|
1502
1504
|
var WATCHDOG_PROBE_INTERVAL_MS = 5 * 60 * 1e3;
|
|
1503
1505
|
var WATCHDOG_PROBE_TIMEOUT_MS = 1e4;
|
|
1504
1506
|
var WATCHDOG_SELF_PING_INTERVAL_MS = 10 * 60 * 1e3;
|
|
@@ -3283,8 +3285,15 @@ var VALID_TRANSITIONS = {
|
|
|
3283
3285
|
delivered: [],
|
|
3284
3286
|
failed: []
|
|
3285
3287
|
};
|
|
3288
|
+
var MAX_DOUBLE_SETTLE_WARNINGS = 20;
|
|
3286
3289
|
var JobLedger = class {
|
|
3287
3290
|
entries = /* @__PURE__ */ new Map();
|
|
3291
|
+
/**
|
|
3292
|
+
* settlement signature -> the job that consumed it. Derived state, rebuilt
|
|
3293
|
+
* from `entries` on every {@link load} - the entries themselves are the
|
|
3294
|
+
* durable record.
|
|
3295
|
+
*/
|
|
3296
|
+
paymentSignatureOwners = /* @__PURE__ */ new Map();
|
|
3288
3297
|
path;
|
|
3289
3298
|
/**
|
|
3290
3299
|
* @param ledgerPath absolute path to the ledger file
|
|
@@ -3299,8 +3308,23 @@ var JobLedger = class {
|
|
|
3299
3308
|
try {
|
|
3300
3309
|
const raw = readFileSync(this.path, "utf-8");
|
|
3301
3310
|
const data = JSON.parse(raw);
|
|
3311
|
+
let unusable = 0;
|
|
3302
3312
|
for (const [id, entry] of Object.entries(data)) {
|
|
3303
|
-
|
|
3313
|
+
if (entry === null || typeof entry !== "object" || Array.isArray(entry)) {
|
|
3314
|
+
unusable += 1;
|
|
3315
|
+
continue;
|
|
3316
|
+
}
|
|
3317
|
+
const candidate = entry;
|
|
3318
|
+
if (typeof candidate.job_id !== "string" || candidate.job_id.length === 0) {
|
|
3319
|
+
unusable += 1;
|
|
3320
|
+
continue;
|
|
3321
|
+
}
|
|
3322
|
+
this.entries.set(id, candidate);
|
|
3323
|
+
}
|
|
3324
|
+
if (unusable > 0) {
|
|
3325
|
+
console.warn(
|
|
3326
|
+
` ! Ledger load warning: skipped ${unusable} unusable ${unusable === 1 ? "entry" : "entries"} in ${this.path} - not an object, or carrying no job id. They cannot be routed or recovered, and the next write will not preserve them: copy the file before restarting if its history matters.`
|
|
3327
|
+
);
|
|
3304
3328
|
}
|
|
3305
3329
|
} catch (e) {
|
|
3306
3330
|
if (e?.code !== "ENOENT") {
|
|
@@ -3313,6 +3337,7 @@ var JobLedger = class {
|
|
|
3313
3337
|
}
|
|
3314
3338
|
}
|
|
3315
3339
|
}
|
|
3340
|
+
this.indexPaymentSignatures();
|
|
3316
3341
|
}
|
|
3317
3342
|
flush() {
|
|
3318
3343
|
const dir = dirname(this.path);
|
|
@@ -3320,8 +3345,8 @@ var JobLedger = class {
|
|
|
3320
3345
|
const obj = Object.fromEntries(this.entries);
|
|
3321
3346
|
const tmp = this.path + ".tmp";
|
|
3322
3347
|
writeFileSync(tmp, JSON.stringify(obj, null, 2), { mode: LEDGER_FILE_MODE });
|
|
3348
|
+
chmodSync(tmp, LEDGER_FILE_MODE);
|
|
3323
3349
|
renameSync(tmp, this.path);
|
|
3324
|
-
chmodSync(this.path, LEDGER_FILE_MODE);
|
|
3325
3350
|
}
|
|
3326
3351
|
recordPaid(entry) {
|
|
3327
3352
|
if (this.entries.has(entry.job_id)) {
|
|
@@ -3376,6 +3401,121 @@ var JobLedger = class {
|
|
|
3376
3401
|
this.flush();
|
|
3377
3402
|
}
|
|
3378
3403
|
}
|
|
3404
|
+
/**
|
|
3405
|
+
* Build the settlement-signature index from the loaded entries. A signature
|
|
3406
|
+
* recorded on two entries is the on-disk fingerprint of a double settle -
|
|
3407
|
+
* shout about it rather than silently picking a winner, but once per SIGNATURE
|
|
3408
|
+
* and capped overall: a corrupt ledger repeating one signature across
|
|
3409
|
+
* thousands of entries buried every other startup line under ~30 000
|
|
3410
|
+
* identical warnings. The suppressed count is reported at the end.
|
|
3411
|
+
*/
|
|
3412
|
+
indexPaymentSignatures() {
|
|
3413
|
+
const warnedSignatures = /* @__PURE__ */ new Set();
|
|
3414
|
+
let suppressed = 0;
|
|
3415
|
+
for (const entry of this.entries.values()) {
|
|
3416
|
+
const paymentSignature = entry.payment_signature;
|
|
3417
|
+
if (typeof paymentSignature !== "string" || paymentSignature.length === 0) {
|
|
3418
|
+
continue;
|
|
3419
|
+
}
|
|
3420
|
+
const owner = this.paymentSignatureOwners.get(paymentSignature);
|
|
3421
|
+
if (owner !== void 0 && owner !== entry.job_id) {
|
|
3422
|
+
if (warnedSignatures.has(paymentSignature)) {
|
|
3423
|
+
continue;
|
|
3424
|
+
}
|
|
3425
|
+
warnedSignatures.add(paymentSignature);
|
|
3426
|
+
if (warnedSignatures.size > MAX_DOUBLE_SETTLE_WARNINGS) {
|
|
3427
|
+
suppressed += 1;
|
|
3428
|
+
continue;
|
|
3429
|
+
}
|
|
3430
|
+
console.warn(
|
|
3431
|
+
` ! DOUBLE SETTLE in the ledger: on-chain settlement ${paymentSignature} is recorded for BOTH job ${owner} and job ${entry.job_id}. One transaction must settle only one job - audit both before trusting this agent's payment history.`
|
|
3432
|
+
);
|
|
3433
|
+
continue;
|
|
3434
|
+
}
|
|
3435
|
+
this.paymentSignatureOwners.set(paymentSignature, entry.job_id);
|
|
3436
|
+
}
|
|
3437
|
+
if (suppressed > 0) {
|
|
3438
|
+
console.warn(
|
|
3439
|
+
` ! DOUBLE SETTLE: ${suppressed} further duplicated settlement signature(s) not listed. This ledger is corrupt - audit it in full.`
|
|
3440
|
+
);
|
|
3441
|
+
}
|
|
3442
|
+
}
|
|
3443
|
+
/** The job that consumed `paymentSignature`, or undefined when unclaimed. */
|
|
3444
|
+
paymentSignatureOwner(paymentSignature) {
|
|
3445
|
+
return this.paymentSignatureOwners.get(paymentSignature);
|
|
3446
|
+
}
|
|
3447
|
+
/**
|
|
3448
|
+
* Bind an on-chain settlement signature to exactly one job - the whole of
|
|
3449
|
+
* "one transaction settles one job" for the FLAT paid path. It exists because
|
|
3450
|
+
* the SDK verifier is stateless by contract (see
|
|
3451
|
+
* `PaymentStrategy.verifyPayment`), so one transfer carrying N job references
|
|
3452
|
+
* verifies for all N and only the provider can pick the single job it settles.
|
|
3453
|
+
*
|
|
3454
|
+
* The exactly-once property comes from this method being SYNCHRONOUS: index
|
|
3455
|
+
* read and write-back happen in one uninterrupted turn of the event loop, so
|
|
3456
|
+
* N concurrent jobs presenting the same signature serialize and exactly one
|
|
3457
|
+
* wins - the same shape as {@link UsedNonceStore}'s `has` -> `markUsed` pair.
|
|
3458
|
+
* Never make this async.
|
|
3459
|
+
*
|
|
3460
|
+
* SCOPE: one ledger file, i.e. one agent directory, in one process. Two
|
|
3461
|
+
* `elisym start` processes sharing a directory are deliberately not
|
|
3462
|
+
* serialized against each other (no cross-process lock) - run one per agent
|
|
3463
|
+
* directory. Separate directories are separate providers with separate
|
|
3464
|
+
* wallets, so they have nothing to de-duplicate in common.
|
|
3465
|
+
*
|
|
3466
|
+
* A re-claim by the SAME job always succeeds: live re-confirmation and crash
|
|
3467
|
+
* recovery re-verifying its own payment must both keep working.
|
|
3468
|
+
*
|
|
3469
|
+
* A job that claims a SECOND, DIFFERENT signature RELEASES the first one's
|
|
3470
|
+
* index mark, because the entry carries exactly one `payment_signature` and
|
|
3471
|
+
* the index must not outlive the entry field that justifies it - the prune
|
|
3472
|
+
* path relies on "every mark corresponds to a persisted `payment_signature`".
|
|
3473
|
+
* Releasing it is safe, not generous: a second claim is only reachable while
|
|
3474
|
+
* the job is still unconfirmed (`net_amount` unset, which is the only state
|
|
3475
|
+
* that re-runs verification), so the released signature settled nothing and
|
|
3476
|
+
* is exactly as unowned as it was before this job ever looked at it.
|
|
3477
|
+
*
|
|
3478
|
+
* That release is guarded on OWNERSHIP, and so is its rollback. On a ledger
|
|
3479
|
+
* that already double-settles (two entries carrying one signature -
|
|
3480
|
+
* `indexPaymentSignatures` warns and keeps the first as owner), this entry's
|
|
3481
|
+
* `payment_signature` can name a mark another job holds. Releasing it
|
|
3482
|
+
* unguarded would free a settlement the rightful owner still needs, and
|
|
3483
|
+
* re-assigning it unguarded on a failed flush would hand that owner's mark to
|
|
3484
|
+
* this job - refusing the owner its own settlement until the next restart.
|
|
3485
|
+
*/
|
|
3486
|
+
claimPaymentSignature(paymentSignature, jobId) {
|
|
3487
|
+
const entry = this.entries.get(jobId);
|
|
3488
|
+
if (entry === void 0) {
|
|
3489
|
+
return "unknown-job";
|
|
3490
|
+
}
|
|
3491
|
+
const owner = this.paymentSignatureOwners.get(paymentSignature);
|
|
3492
|
+
if (owner !== void 0 && owner !== jobId) {
|
|
3493
|
+
return "consumed-by-other";
|
|
3494
|
+
}
|
|
3495
|
+
const previousSignature = entry.payment_signature;
|
|
3496
|
+
const previousOwner = owner;
|
|
3497
|
+
const releasedSignature = typeof previousSignature === "string" && previousSignature !== paymentSignature && this.paymentSignatureOwners.get(previousSignature) === jobId ? previousSignature : void 0;
|
|
3498
|
+
this.paymentSignatureOwners.set(paymentSignature, jobId);
|
|
3499
|
+
if (releasedSignature !== void 0) {
|
|
3500
|
+
this.paymentSignatureOwners.delete(releasedSignature);
|
|
3501
|
+
}
|
|
3502
|
+
entry.payment_signature = paymentSignature;
|
|
3503
|
+
try {
|
|
3504
|
+
this.flush();
|
|
3505
|
+
} catch {
|
|
3506
|
+
entry.payment_signature = previousSignature;
|
|
3507
|
+
if (previousOwner === void 0) {
|
|
3508
|
+
this.paymentSignatureOwners.delete(paymentSignature);
|
|
3509
|
+
} else {
|
|
3510
|
+
this.paymentSignatureOwners.set(paymentSignature, previousOwner);
|
|
3511
|
+
}
|
|
3512
|
+
if (releasedSignature !== void 0) {
|
|
3513
|
+
this.paymentSignatureOwners.set(releasedSignature, jobId);
|
|
3514
|
+
}
|
|
3515
|
+
return "not-persisted";
|
|
3516
|
+
}
|
|
3517
|
+
return "claimed";
|
|
3518
|
+
}
|
|
3379
3519
|
/** Attempt a state transition. Returns the entry if valid, undefined otherwise. */
|
|
3380
3520
|
transition(jobId, to) {
|
|
3381
3521
|
const entry = this.entries.get(jobId);
|
|
@@ -3448,10 +3588,6 @@ var JobLedger = class {
|
|
|
3448
3588
|
allEntries() {
|
|
3449
3589
|
return [...this.entries.values()];
|
|
3450
3590
|
}
|
|
3451
|
-
/** Remove old delivered/failed entries (default: 7 days). */
|
|
3452
|
-
gc(maxAgeSecs = 7 * 24 * 60 * 60) {
|
|
3453
|
-
this.pruneOldEntries(maxAgeSecs * 1e3);
|
|
3454
|
-
}
|
|
3455
3591
|
/**
|
|
3456
3592
|
* Drop terminal entries (`delivered` / `failed`) whose `created_at`
|
|
3457
3593
|
* predates `now - retentionMs`. Stuck non-terminal entries are never
|
|
@@ -3462,18 +3598,48 @@ var JobLedger = class {
|
|
|
3462
3598
|
*/
|
|
3463
3599
|
pruneOldEntries(retentionMs) {
|
|
3464
3600
|
const cutoff = Math.floor(Date.now() / 1e3) - Math.floor(retentionMs / 1e3);
|
|
3601
|
+
const prunedSignatures = /* @__PURE__ */ new Set();
|
|
3465
3602
|
let deleted = 0;
|
|
3466
3603
|
for (const [id, entry] of this.entries) {
|
|
3467
3604
|
if ((entry.status === "delivered" || entry.status === "failed") && entry.created_at < cutoff) {
|
|
3468
3605
|
this.entries.delete(id);
|
|
3606
|
+
if (typeof entry.payment_signature === "string" && entry.payment_signature.length > 0) {
|
|
3607
|
+
prunedSignatures.add(entry.payment_signature);
|
|
3608
|
+
}
|
|
3469
3609
|
deleted += 1;
|
|
3470
3610
|
}
|
|
3471
3611
|
}
|
|
3612
|
+
if (prunedSignatures.size > 0) {
|
|
3613
|
+
this.releasePrunedPaymentSignatures(prunedSignatures);
|
|
3614
|
+
}
|
|
3472
3615
|
if (deleted > 0) {
|
|
3473
3616
|
this.flush();
|
|
3474
3617
|
}
|
|
3475
3618
|
return deleted;
|
|
3476
3619
|
}
|
|
3620
|
+
/**
|
|
3621
|
+
* Release the index marks of PRUNED entries, so the index cannot outgrow the
|
|
3622
|
+
* ledger. Every mark corresponds to a persisted `payment_signature` (a claim
|
|
3623
|
+
* whose flush failed rolls both back), so the entries are the whole story.
|
|
3624
|
+
* Safe: `LEDGER_RETENTION_MS` is far past Solana's ~2-3 day history horizon,
|
|
3625
|
+
* so a pruned signature can no longer verify for any job. A signature still
|
|
3626
|
+
* carried by a SURVIVING entry (the fingerprint of a double settle) is kept
|
|
3627
|
+
* whoever owns it - pruning one side must never hand the transaction to a
|
|
3628
|
+
* third job.
|
|
3629
|
+
*/
|
|
3630
|
+
releasePrunedPaymentSignatures(prunedSignatures) {
|
|
3631
|
+
const survivingSignatures = /* @__PURE__ */ new Set();
|
|
3632
|
+
for (const entry of this.entries.values()) {
|
|
3633
|
+
if (typeof entry.payment_signature === "string") {
|
|
3634
|
+
survivingSignatures.add(entry.payment_signature);
|
|
3635
|
+
}
|
|
3636
|
+
}
|
|
3637
|
+
for (const paymentSignature of prunedSignatures) {
|
|
3638
|
+
if (!survivingSignatures.has(paymentSignature)) {
|
|
3639
|
+
this.paymentSignatureOwners.delete(paymentSignature);
|
|
3640
|
+
}
|
|
3641
|
+
}
|
|
3642
|
+
}
|
|
3477
3643
|
};
|
|
3478
3644
|
var NONCE_STORE_MAX_ENTRIES = 1e4;
|
|
3479
3645
|
var UsedNonceStore = class {
|
|
@@ -3638,9 +3804,6 @@ function resolveProviderApiKey(input) {
|
|
|
3638
3804
|
error: `Provider "${provider}" needs an API key (required by skill(s): ${skillList}). Set secrets.llm_api_keys.${provider} via 'npx @elisym/cli profile <agent>' or export ${descriptor.envVar}.`
|
|
3639
3805
|
};
|
|
3640
3806
|
}
|
|
3641
|
-
function sanitizeForTerminal(value) {
|
|
3642
|
-
return value.replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, "");
|
|
3643
|
-
}
|
|
3644
3807
|
function resolveLevel(options) {
|
|
3645
3808
|
if (options.level) {
|
|
3646
3809
|
return options.level;
|
|
@@ -3679,7 +3842,7 @@ function createLogger(options = {}) {
|
|
|
3679
3842
|
logger = pino(baseOptions, pino.destination(2));
|
|
3680
3843
|
}
|
|
3681
3844
|
function logWithIndent(line) {
|
|
3682
|
-
process.stdout.write(` ${
|
|
3845
|
+
process.stdout.write(` ${deleteControlCharacters(line)}
|
|
3683
3846
|
`);
|
|
3684
3847
|
}
|
|
3685
3848
|
return {
|
|
@@ -3700,6 +3863,664 @@ var MIME_BY_EXT = {
|
|
|
3700
3863
|
function mimeFromPath(path) {
|
|
3701
3864
|
return MIME_BY_EXT[extname(path).toLowerCase()] ?? "application/octet-stream";
|
|
3702
3865
|
}
|
|
3866
|
+
var REFERENCE_SCAN_WINDOW = DEFAULTS.VERIFY_SIGNATURE_LIMIT;
|
|
3867
|
+
var REFERENCE_SCAN_LIST_ATTEMPTS = 3;
|
|
3868
|
+
var REFERENCE_SCAN_LIST_RETRY_DELAY_MS = 400;
|
|
3869
|
+
var REFERENCE_SCAN_VERIFY_RETRIES = REFERENCE_SCAN_LIST_ATTEMPTS;
|
|
3870
|
+
var OWN_SETTLEMENT_VERIFY_RETRIES = REFERENCE_SCAN_VERIFY_RETRIES + 2;
|
|
3871
|
+
var REFERENCE_SCAN_DEADLINE_MS = 3e4;
|
|
3872
|
+
var CLUSTER_GENESIS_HASHES = {
|
|
3873
|
+
mainnet: "5eykt4UsFv8P8NJdTREpY1vzqKqZKvdpKuc147dw2N9d",
|
|
3874
|
+
devnet: "EtWTRABZaYq6iMfeYKouRu166VU2xqa1wcaWoxPkrZBG"
|
|
3875
|
+
};
|
|
3876
|
+
var ADDRESS_HISTORY_PROBE_ADDRESS = "11111111111111111111111111111111";
|
|
3877
|
+
var RECOVERY_DEFER_BACKOFF_TICKS = [1, 2, 5, 15, 30, 60];
|
|
3878
|
+
var RECOVERY_DEFER_JITTER_TICKS = 0.5;
|
|
3879
|
+
var TERMINAL_CONFIRMATION_MIN_RUNG = 3;
|
|
3880
|
+
var RECOVERY_SCAN_BUDGET_DIVISOR = 2;
|
|
3881
|
+
function recoveryScanBudgetPerTick(maxConcurrentJobs) {
|
|
3882
|
+
return Math.max(1, Math.floor(maxConcurrentJobs / RECOVERY_SCAN_BUDGET_DIVISOR));
|
|
3883
|
+
}
|
|
3884
|
+
function recoveryDeferDelayMs(attempts, recoveryIntervalSecs, jitter) {
|
|
3885
|
+
const rung = Math.min(attempts, RECOVERY_DEFER_BACKOFF_TICKS.length) - 1;
|
|
3886
|
+
const ticks = RECOVERY_DEFER_BACKOFF_TICKS[rung] ?? 1;
|
|
3887
|
+
const intervalMs = recoveryIntervalSecs * 1e3;
|
|
3888
|
+
const jitterMs = jitter * RECOVERY_DEFER_JITTER_TICKS * intervalMs;
|
|
3889
|
+
return ticks * intervalMs + jitterMs;
|
|
3890
|
+
}
|
|
3891
|
+
function waitMs(ms) {
|
|
3892
|
+
return new Promise((resolve4) => {
|
|
3893
|
+
setTimeout(resolve4, ms);
|
|
3894
|
+
});
|
|
3895
|
+
}
|
|
3896
|
+
function needsPaymentScan(entry, delegated) {
|
|
3897
|
+
return entry.status === "paid" && !entry.net_amount && entry.payment_request !== void 0 && !delegated;
|
|
3898
|
+
}
|
|
3899
|
+
function claimRefusalSentence(claim) {
|
|
3900
|
+
switch (claim) {
|
|
3901
|
+
case "consumed-by-other":
|
|
3902
|
+
return "the transaction carrying this job reference was already consumed by another job, so it is not attributable here";
|
|
3903
|
+
case "not-persisted":
|
|
3904
|
+
return "the claim on that settlement could not be written to disk, so the payment was refused rather than risk delivering twice for one transfer";
|
|
3905
|
+
case "unknown-job":
|
|
3906
|
+
return "this job has no ledger entry, so the claim had nowhere durable to live";
|
|
3907
|
+
}
|
|
3908
|
+
}
|
|
3909
|
+
var UNNAMED_SETTLEMENT_SENTENCE = "the payment verified without a settlement signature, so it cannot be bound to one job";
|
|
3910
|
+
var RecoveryDeferrals = class {
|
|
3911
|
+
/**
|
|
3912
|
+
* `recoveryIntervalSecs` is read through a function because the backoff
|
|
3913
|
+
* ladder is counted in TICKS and the runtime owns the tick length.
|
|
3914
|
+
* `random` returns `[0, 1)`; injected so the jitter is testable.
|
|
3915
|
+
*/
|
|
3916
|
+
constructor(recoveryIntervalSecs, random = Math.random) {
|
|
3917
|
+
this.recoveryIntervalSecs = recoveryIntervalSecs;
|
|
3918
|
+
this.random = random;
|
|
3919
|
+
}
|
|
3920
|
+
deferrals = /* @__PURE__ */ new Map();
|
|
3921
|
+
/** How many entries are currently held back. */
|
|
3922
|
+
get size() {
|
|
3923
|
+
return this.deferrals.size;
|
|
3924
|
+
}
|
|
3925
|
+
/** This entry's deferral state, or undefined when it is not held back. */
|
|
3926
|
+
peek(jobId) {
|
|
3927
|
+
return this.deferrals.get(jobId);
|
|
3928
|
+
}
|
|
3929
|
+
/**
|
|
3930
|
+
* Record that a recovery tick ended INCONCLUSIVE for this entry and push its
|
|
3931
|
+
* next attempt out by the backoff ladder.
|
|
3932
|
+
*
|
|
3933
|
+
* A deferral is cheap to decide and expensive to repeat: a full reference
|
|
3934
|
+
* scan (an RPC listing plus a transaction fetch per candidate) holding a
|
|
3935
|
+
* `p-limit` slot and a queue slot shared with live intake. Repeating that
|
|
3936
|
+
* every 60s for 24h is how a handful of stuck entries turns into "Server
|
|
3937
|
+
* overloaded" for paying customers. Retries are still NOT burned - a deferral
|
|
3938
|
+
* is never evidence against the customer - the wait simply lengthens.
|
|
3939
|
+
*
|
|
3940
|
+
* `sawNoPayment` carries forward the one observation that can ever become
|
|
3941
|
+
* terminal, and is cleared by any other outcome - that is what "two
|
|
3942
|
+
* CONSECUTIVE clean empty scans" means.
|
|
3943
|
+
*/
|
|
3944
|
+
note(jobId, sawNoPayment = false) {
|
|
3945
|
+
const previous = this.deferrals.get(jobId);
|
|
3946
|
+
const attempts = (previous?.attempts ?? 0) + 1;
|
|
3947
|
+
const delayMs = recoveryDeferDelayMs(
|
|
3948
|
+
sawNoPayment ? Math.max(attempts, TERMINAL_CONFIRMATION_MIN_RUNG) : attempts,
|
|
3949
|
+
this.recoveryIntervalSecs(),
|
|
3950
|
+
this.random() * 2 - 1
|
|
3951
|
+
);
|
|
3952
|
+
this.deferrals.set(jobId, {
|
|
3953
|
+
attempts,
|
|
3954
|
+
nextAttemptAt: Date.now() + delayMs,
|
|
3955
|
+
firstDeferredAt: previous?.firstDeferredAt ?? Date.now(),
|
|
3956
|
+
sawNoPayment
|
|
3957
|
+
});
|
|
3958
|
+
}
|
|
3959
|
+
/**
|
|
3960
|
+
* Record a scan that found an empty reference INSIDE the payment window this
|
|
3961
|
+
* provider itself advertised.
|
|
3962
|
+
*
|
|
3963
|
+
* Deliberately off the backoff ladder. The ladder counts inconclusive looks -
|
|
3964
|
+
* moments where the chain, the RPC or our own disk let us down - and "the
|
|
3965
|
+
* customer still has eight of their ten minutes left" is none of those: it is
|
|
3966
|
+
* the expected state of an ordinary job that nobody has paid yet. Counting it
|
|
3967
|
+
* cost twice over. A customer whose wallet confirms four minutes in was not
|
|
3968
|
+
* looked at again until minute nine, because the ladder had already climbed to
|
|
3969
|
+
* its 5-tick rung while they were still entitled to pay. And a job nobody ever
|
|
3970
|
+
* pays reached its expiry already parked on a 15- or 30-tick rung, so the two
|
|
3971
|
+
* consecutive scans that close it landed the better part of an hour later -
|
|
3972
|
+
* holding a `paid` entry, and its recovery slot, for all of it.
|
|
3973
|
+
*
|
|
3974
|
+
* So: hold the entry back for one tick, leave `attempts` where it is, and let
|
|
3975
|
+
* the ladder start climbing only once the window is actually over. The cost is
|
|
3976
|
+
* one `getSignaturesForAddress` per tick per unpaid job for the length of the
|
|
3977
|
+
* window (ten minutes by default) - an empty scan fetches no transactions -
|
|
3978
|
+
* and the per-tick scan budget still bounds how many run at once.
|
|
3979
|
+
*
|
|
3980
|
+
* `sawNoPayment` is cleared for the same reason it is cleared on any other
|
|
3981
|
+
* inconclusive outcome: this scan must never count toward the two consecutive
|
|
3982
|
+
* sightings that fail a job, because it was taken before the customer's
|
|
3983
|
+
* deadline.
|
|
3984
|
+
*/
|
|
3985
|
+
noteAwaitingWindow(jobId) {
|
|
3986
|
+
const previous = this.deferrals.get(jobId);
|
|
3987
|
+
const delayMs = recoveryDeferDelayMs(1, this.recoveryIntervalSecs(), this.random() * 2 - 1);
|
|
3988
|
+
this.deferrals.set(jobId, {
|
|
3989
|
+
attempts: previous?.attempts ?? 0,
|
|
3990
|
+
nextAttemptAt: Date.now() + delayMs,
|
|
3991
|
+
firstDeferredAt: previous?.firstDeferredAt ?? Date.now(),
|
|
3992
|
+
sawNoPayment: false
|
|
3993
|
+
});
|
|
3994
|
+
}
|
|
3995
|
+
/**
|
|
3996
|
+
* Park an entry whose payment re-scan did not fit in this tick's scan budget.
|
|
3997
|
+
*
|
|
3998
|
+
* NOT a deferral: nothing was attempted, so the ladder does not advance and
|
|
3999
|
+
* the `sawNoPayment` observation is neither set nor cleared. The wait is
|
|
4000
|
+
* deliberately SUB-TICK, so a parked entry is due again by the next tick
|
|
4001
|
+
* rather than pushed minutes into the future for a queue that was merely
|
|
4002
|
+
* busy. What spreads a restart's backlog across ticks is the per-tick budget
|
|
4003
|
+
* itself (see {@link recoveryScanBudgetPerTick}), not this wait; the jitter
|
|
4004
|
+
* only stops a whole parked batch from becoming due on the same millisecond.
|
|
4005
|
+
*/
|
|
4006
|
+
parkForCapacity(jobId) {
|
|
4007
|
+
const previous = this.deferrals.get(jobId);
|
|
4008
|
+
const jitteredMs = this.random() * this.recoveryIntervalSecs() * 1e3;
|
|
4009
|
+
this.deferrals.set(jobId, {
|
|
4010
|
+
attempts: previous?.attempts ?? 0,
|
|
4011
|
+
nextAttemptAt: Date.now() + jitteredMs,
|
|
4012
|
+
firstDeferredAt: previous?.firstDeferredAt ?? Date.now(),
|
|
4013
|
+
sawNoPayment: previous?.sawNoPayment ?? false
|
|
4014
|
+
});
|
|
4015
|
+
}
|
|
4016
|
+
/**
|
|
4017
|
+
* Forget this entry's deferral state entirely. Called when the entry stops
|
|
4018
|
+
* being deferred at all - its payment confirmed - so the backlog summary's
|
|
4019
|
+
* "oldest deferred" cannot keep ageing on an entry nobody is holding back.
|
|
4020
|
+
*/
|
|
4021
|
+
clear(jobId) {
|
|
4022
|
+
this.deferrals.delete(jobId);
|
|
4023
|
+
}
|
|
4024
|
+
/** Whether the LAST attempt on this entry saw a complete, empty reference history. */
|
|
4025
|
+
sawNoPayment(jobId) {
|
|
4026
|
+
return this.deferrals.get(jobId)?.sawNoPayment === true;
|
|
4027
|
+
}
|
|
4028
|
+
/** Whether this entry is still inside its deferral backoff window. */
|
|
4029
|
+
isDeferred(jobId) {
|
|
4030
|
+
const deferral = this.deferrals.get(jobId);
|
|
4031
|
+
return deferral !== void 0 && Date.now() < deferral.nextAttemptAt;
|
|
4032
|
+
}
|
|
4033
|
+
/**
|
|
4034
|
+
* Drop the state of entries that have left the pending set (delivered,
|
|
4035
|
+
* failed, pruned), keeping this map bounded by the pending set rather than by
|
|
4036
|
+
* the ledger's whole history.
|
|
4037
|
+
*/
|
|
4038
|
+
sweep(stillPending) {
|
|
4039
|
+
if (this.deferrals.size === 0) {
|
|
4040
|
+
return;
|
|
4041
|
+
}
|
|
4042
|
+
for (const jobId of [...this.deferrals.keys()]) {
|
|
4043
|
+
if (!stillPending.has(jobId)) {
|
|
4044
|
+
this.deferrals.delete(jobId);
|
|
4045
|
+
}
|
|
4046
|
+
}
|
|
4047
|
+
}
|
|
4048
|
+
/**
|
|
4049
|
+
* One line per tick summing up the backlog, or `null` when there is nothing
|
|
4050
|
+
* to say. A pinned entry otherwise produces the same per-entry line as a
|
|
4051
|
+
* flaky RPC, roughly thirty times a day, and an operator has no way to tell
|
|
4052
|
+
* "one job is stuck" from "the chain is unreachable". The oldest age is the
|
|
4053
|
+
* number that matters: it says how close the backlog is to the 24h cutoff.
|
|
4054
|
+
*/
|
|
4055
|
+
summaryLine() {
|
|
4056
|
+
if (this.deferrals.size === 0) {
|
|
4057
|
+
return null;
|
|
4058
|
+
}
|
|
4059
|
+
let oldestFirstDeferredAt = Number.POSITIVE_INFINITY;
|
|
4060
|
+
for (const deferral of this.deferrals.values()) {
|
|
4061
|
+
oldestFirstDeferredAt = Math.min(oldestFirstDeferredAt, deferral.firstDeferredAt);
|
|
4062
|
+
}
|
|
4063
|
+
const oldestMinutes = Math.floor((Date.now() - oldestFirstDeferredAt) / 6e4);
|
|
4064
|
+
const noun = this.deferrals.size === 1 ? "job is" : "jobs are";
|
|
4065
|
+
return `Recovery: ${this.deferrals.size} ${noun} deferred awaiting payment confirmation (oldest deferred ${oldestMinutes}m ago; the 24h cutoff closes them).`;
|
|
4066
|
+
}
|
|
4067
|
+
};
|
|
4068
|
+
var PaymentRecovery = class {
|
|
4069
|
+
constructor(ledger, network, fetchProtocolConfig, strategy) {
|
|
4070
|
+
this.ledger = ledger;
|
|
4071
|
+
this.network = network;
|
|
4072
|
+
this.fetchProtocolConfig = fetchProtocolConfig;
|
|
4073
|
+
this.strategy = strategy;
|
|
4074
|
+
}
|
|
4075
|
+
/**
|
|
4076
|
+
* Whether the RPC endpoint has earned the right to end a paying customer's
|
|
4077
|
+
* job. Process-wide and cached, because it is a property of the URL this
|
|
4078
|
+
* process was started with, not of any one scan.
|
|
4079
|
+
*
|
|
4080
|
+
* Only the two STABLE answers are cached. `wrong-cluster` cannot change
|
|
4081
|
+
* without a restart, and `sound` is not worth re-proving on every verdict.
|
|
4082
|
+
* Everything else (an unreachable endpoint, a throttled probe, a node that
|
|
4083
|
+
* refuses address history) stays `unchecked` and is re-probed next tick: an
|
|
4084
|
+
* endpoint that is merely down must not be condemned permanently, and one
|
|
4085
|
+
* that never answers simply never unlocks the verdict.
|
|
4086
|
+
*/
|
|
4087
|
+
endpointSoundness = "unchecked";
|
|
4088
|
+
/**
|
|
4089
|
+
* The probe currently in flight, so several entries reaching a verdict on the
|
|
4090
|
+
* same tick share ONE pair of RPC calls instead of each making its own before
|
|
4091
|
+
* the first has had a chance to cache its answer.
|
|
4092
|
+
*/
|
|
4093
|
+
endpointProbe;
|
|
4094
|
+
/** Reason tags already shouted about, so the loud line is loud exactly once each. */
|
|
4095
|
+
warnedEndpointReasons = /* @__PURE__ */ new Set();
|
|
4096
|
+
/** Shout about an endpoint we will not end jobs on - once per distinct reason. */
|
|
4097
|
+
warnUntrustedEndpoint(reason, log, detail) {
|
|
4098
|
+
if (this.warnedEndpointReasons.has(reason)) {
|
|
4099
|
+
return;
|
|
4100
|
+
}
|
|
4101
|
+
this.warnedEndpointReasons.add(reason);
|
|
4102
|
+
log(
|
|
4103
|
+
` ! WARNING: the Solana RPC endpoint cannot be trusted to decide that a customer did not pay: ${detail} Paid jobs whose payment cannot be found will NOT be closed as unpaid - they stay recoverable until the 24h cutoff and then fail as "the agent did not recover". Check SOLANA_RPC_URL and that it serves the ${this.network} cluster with transaction history enabled.`
|
|
4104
|
+
);
|
|
4105
|
+
}
|
|
4106
|
+
/**
|
|
4107
|
+
* Whether this endpoint may be believed when it says a reference is empty.
|
|
4108
|
+
*
|
|
4109
|
+
* The terminal no-payment verdict is the one place the provider tells a paying
|
|
4110
|
+
* customer their money bought nothing, and every input to it comes from a
|
|
4111
|
+
* single RPC client named by an environment variable nothing checks. Two
|
|
4112
|
+
* consecutive empty listings from the WRONG CLUSTER, or from a node that does
|
|
4113
|
+
* not serve address history at all, are two copies of the same meaningless
|
|
4114
|
+
* answer - and would fail every paying customer of that agent.
|
|
4115
|
+
*
|
|
4116
|
+
* So before the verdict is allowed at all: the endpoint must report the
|
|
4117
|
+
* genesis hash of the cluster this agent is configured for, and must answer a
|
|
4118
|
+
* `getSignaturesForAddress` query. Neither costs anything after the first
|
|
4119
|
+
* success - the answer is cached for the process.
|
|
4120
|
+
*
|
|
4121
|
+
* Fails SAFE: anything short of both checks passing means "keep deferring",
|
|
4122
|
+
* which ends at the 24h cutoff as "the agent did not recover" rather than as a
|
|
4123
|
+
* false accusation. An operator running against a local validator (whose
|
|
4124
|
+
* genesis is its own) therefore never gets the fast verdict; that is the
|
|
4125
|
+
* intended trade.
|
|
4126
|
+
*/
|
|
4127
|
+
endpointMayIssueTerminalVerdict(rpc, log) {
|
|
4128
|
+
if (this.endpointSoundness === "sound") {
|
|
4129
|
+
return Promise.resolve(true);
|
|
4130
|
+
}
|
|
4131
|
+
if (this.endpointSoundness === "wrong-cluster") {
|
|
4132
|
+
return Promise.resolve(false);
|
|
4133
|
+
}
|
|
4134
|
+
if (this.endpointProbe === void 0) {
|
|
4135
|
+
const probe = this.probeEndpointSoundness(rpc, log);
|
|
4136
|
+
this.endpointProbe = probe;
|
|
4137
|
+
const release = () => {
|
|
4138
|
+
if (this.endpointProbe === probe) {
|
|
4139
|
+
this.endpointProbe = void 0;
|
|
4140
|
+
}
|
|
4141
|
+
};
|
|
4142
|
+
void probe.then(release, release);
|
|
4143
|
+
}
|
|
4144
|
+
return this.endpointProbe;
|
|
4145
|
+
}
|
|
4146
|
+
/** The two RPC calls behind {@link endpointMayIssueTerminalVerdict}. */
|
|
4147
|
+
async probeEndpointSoundness(rpc, log) {
|
|
4148
|
+
try {
|
|
4149
|
+
const expectedGenesisHash = CLUSTER_GENESIS_HASHES[this.network];
|
|
4150
|
+
let genesisHash;
|
|
4151
|
+
try {
|
|
4152
|
+
genesisHash = await rpc.getGenesisHash().send();
|
|
4153
|
+
} catch (e) {
|
|
4154
|
+
this.warnUntrustedEndpoint(
|
|
4155
|
+
"unreachable",
|
|
4156
|
+
log,
|
|
4157
|
+
`it did not answer getGenesisHash (${e?.message ?? "unknown error"}).`
|
|
4158
|
+
);
|
|
4159
|
+
return false;
|
|
4160
|
+
}
|
|
4161
|
+
if (genesisHash !== expectedGenesisHash) {
|
|
4162
|
+
this.endpointSoundness = "wrong-cluster";
|
|
4163
|
+
this.warnUntrustedEndpoint(
|
|
4164
|
+
"wrong-cluster",
|
|
4165
|
+
log,
|
|
4166
|
+
`it reports genesis ${genesisHash}, but this agent is configured for ${this.network}, whose genesis is ${expectedGenesisHash}.`
|
|
4167
|
+
);
|
|
4168
|
+
return false;
|
|
4169
|
+
}
|
|
4170
|
+
try {
|
|
4171
|
+
await rpc.getSignaturesForAddress(address(ADDRESS_HISTORY_PROBE_ADDRESS), {
|
|
4172
|
+
limit: 1,
|
|
4173
|
+
commitment: "confirmed"
|
|
4174
|
+
}).send();
|
|
4175
|
+
} catch (e) {
|
|
4176
|
+
this.warnUntrustedEndpoint(
|
|
4177
|
+
"no-history",
|
|
4178
|
+
log,
|
|
4179
|
+
`it is on the right cluster but did not answer an address-history query (${e?.message ?? "unknown error"}).`
|
|
4180
|
+
);
|
|
4181
|
+
return false;
|
|
4182
|
+
}
|
|
4183
|
+
this.endpointSoundness = "sound";
|
|
4184
|
+
return true;
|
|
4185
|
+
} catch (e) {
|
|
4186
|
+
this.warnUntrustedEndpoint(
|
|
4187
|
+
"unreachable",
|
|
4188
|
+
log,
|
|
4189
|
+
`the endpoint check itself failed (${e?.message ?? "unknown error"}).`
|
|
4190
|
+
);
|
|
4191
|
+
return false;
|
|
4192
|
+
}
|
|
4193
|
+
}
|
|
4194
|
+
/**
|
|
4195
|
+
* Bind a verified settlement signature to this job, exactly once - see
|
|
4196
|
+
* `JobLedger.claimPaymentSignature`. Anything but `claimed` means the job is
|
|
4197
|
+
* NOT paid here and now; none of the three refusals is on its own evidence
|
|
4198
|
+
* that the customer failed to pay. Diagnostics log the FULL signature, both
|
|
4199
|
+
* job ids and the customer pubkey - all public values, and an operator
|
|
4200
|
+
* chasing a disputed payment should not have to match truncated prefixes.
|
|
4201
|
+
*/
|
|
4202
|
+
claimSettlementSignature(txSignature, job, log) {
|
|
4203
|
+
const owner = this.ledger.paymentSignatureOwner(txSignature);
|
|
4204
|
+
const outcome = this.ledger.claimPaymentSignature(txSignature, job.jobId);
|
|
4205
|
+
if (outcome === "consumed-by-other") {
|
|
4206
|
+
log(
|
|
4207
|
+
`[${job.jobId.slice(0, 8)}] REFUSED: settlement ${txSignature} was already consumed by job ${owner ?? "unknown"}. One transaction settles one job, so it is not attributable to job ${job.jobId} (customer ${job.customerId}).`
|
|
4208
|
+
);
|
|
4209
|
+
} else if (outcome === "not-persisted") {
|
|
4210
|
+
log(
|
|
4211
|
+
`[${job.jobId.slice(0, 8)}] REFUSED: could not WRITE the claim on settlement ${txSignature} for job ${job.jobId} (customer ${job.customerId}) - refusing the payment rather than risk delivering twice for one transfer. Check disk space and permissions at the agent directory; the job retries on the next recovery tick.`
|
|
4212
|
+
);
|
|
4213
|
+
} else if (outcome === "unknown-job") {
|
|
4214
|
+
log(
|
|
4215
|
+
`[${job.jobId.slice(0, 8)}] REFUSED: job ${job.jobId} (customer ${job.customerId}) has no ledger entry, so the claim on settlement ${txSignature} has nowhere durable to live. This is a provider bug, not a disk problem - every paid job is recorded before payment collection. Report it with this log line.`
|
|
4216
|
+
);
|
|
4217
|
+
}
|
|
4218
|
+
return outcome;
|
|
4219
|
+
}
|
|
4220
|
+
/**
|
|
4221
|
+
* List the transactions that could possibly be a payment for this request:
|
|
4222
|
+
* one `getSignaturesForAddress` window against the reference key, newest
|
|
4223
|
+
* first, minus the transactions that FAILED on chain. A failed transaction
|
|
4224
|
+
* moved no money, so it is neither a payment nor evidence that anyone touched
|
|
4225
|
+
* this reference - counting it would let ~5000 lamports of deliberately
|
|
4226
|
+
* failing transaction (or an honest customer whose transfer simply failed)
|
|
4227
|
+
* turn a job that should close in a minute into a 24h wait.
|
|
4228
|
+
*
|
|
4229
|
+
* Listed at `confirmed`, the level the SDK verifier reads transactions at.
|
|
4230
|
+
* The RPC default `finalized` would hide a confirmed-but-not-yet-finalized
|
|
4231
|
+
* payment from the one check that decides whether anything is there.
|
|
4232
|
+
*
|
|
4233
|
+
* RETRIED (`REFERENCE_SCAN_LIST_ATTEMPTS`): the terminal "nobody paid" verdict
|
|
4234
|
+
* rests entirely on this one call, and the public RPC is known to throttle and
|
|
4235
|
+
* lag this exact index. A single un-retried shot deciding whether a customer
|
|
4236
|
+
* loses their money is not a trade worth making; a false deferral costs
|
|
4237
|
+
* latency, a false "unpaid" destroys money. Retrying stops at the scan
|
|
4238
|
+
* `deadline`: past it the scan is going to return inconclusive anyway, and
|
|
4239
|
+
* the attempts left would only hold a shared slot.
|
|
4240
|
+
*
|
|
4241
|
+
* `windowFull` reports whether the RAW listing came back at the window size.
|
|
4242
|
+
* A full window means the history is TRUNCATED - a payment can be hiding
|
|
4243
|
+
* behind the newer transactions - so the caller must not read "nothing
|
|
4244
|
+
* verified" as "nobody paid". Measured before the failed-transaction filter,
|
|
4245
|
+
* because the limit applies to the raw page.
|
|
4246
|
+
*
|
|
4247
|
+
* An error is never "the customer did not pay" - the caller keeps the job
|
|
4248
|
+
* recoverable.
|
|
4249
|
+
*/
|
|
4250
|
+
async listReferenceCandidates(reference, rpc, deadline) {
|
|
4251
|
+
let lastMessage = "unknown error";
|
|
4252
|
+
let attempts = 0;
|
|
4253
|
+
for (let attempt = 0; attempt < REFERENCE_SCAN_LIST_ATTEMPTS; attempt++) {
|
|
4254
|
+
attempts = attempt + 1;
|
|
4255
|
+
try {
|
|
4256
|
+
const listed = await rpc.getSignaturesForAddress(reference, {
|
|
4257
|
+
limit: REFERENCE_SCAN_WINDOW,
|
|
4258
|
+
commitment: "confirmed"
|
|
4259
|
+
}).send();
|
|
4260
|
+
return {
|
|
4261
|
+
signatures: listed.filter((candidate) => !candidate.err).map((candidate) => candidate.signature),
|
|
4262
|
+
windowFull: listed.length >= REFERENCE_SCAN_WINDOW
|
|
4263
|
+
};
|
|
4264
|
+
} catch (e) {
|
|
4265
|
+
lastMessage = e?.message ?? "unknown error";
|
|
4266
|
+
if (Date.now() >= deadline) {
|
|
4267
|
+
break;
|
|
4268
|
+
}
|
|
4269
|
+
if (attempt < REFERENCE_SCAN_LIST_ATTEMPTS - 1) {
|
|
4270
|
+
await waitMs(REFERENCE_SCAN_LIST_RETRY_DELAY_MS);
|
|
4271
|
+
}
|
|
4272
|
+
}
|
|
4273
|
+
}
|
|
4274
|
+
return {
|
|
4275
|
+
error: `could not reach the chain to list the reference after ${attempts} of ${REFERENCE_SCAN_LIST_ATTEMPTS} attempts: ${lastMessage}`
|
|
4276
|
+
};
|
|
4277
|
+
}
|
|
4278
|
+
/**
|
|
4279
|
+
* Provider-side reference scan: list the reference's transactions and verify
|
|
4280
|
+
* the first one this job may actually own.
|
|
4281
|
+
*
|
|
4282
|
+
* The SDK's own reference path returns the newest VERIFYING transaction in
|
|
4283
|
+
* its window and cannot be asked for the next one. Fine for a customer
|
|
4284
|
+
* confirming their own payment; fatal for a provider, because a stranger who
|
|
4285
|
+
* attaches this job's reference to their own newer transfer makes the genuine
|
|
4286
|
+
* payment invisible to that path and the job dies at the 24h cutoff with the
|
|
4287
|
+
* money already taken. Walking the window and skipping the signatures the
|
|
4288
|
+
* ledger gave to OTHER jobs - which only the provider can know - is what
|
|
4289
|
+
* reaches such a payment.
|
|
4290
|
+
*
|
|
4291
|
+
* EXACTLY WHAT THAT RECOVERS, and no more: a payment masked by transactions
|
|
4292
|
+
* THIS ledger has already consumed, lying INSIDE one window. A mask built from
|
|
4293
|
+
* transactions the ledger knows nothing about is not skipped, only walked past
|
|
4294
|
+
* (each is fetched and rejected, which makes the scan inconclusive rather than
|
|
4295
|
+
* terminal); and a mask longer than the window pushes the payment off the page
|
|
4296
|
+
* entirely, where nothing here can see it. Both of those stay recoverable
|
|
4297
|
+
* until the 24h cutoff rather than being wrongly closed, which is the property
|
|
4298
|
+
* that actually protects the customer's money.
|
|
4299
|
+
*
|
|
4300
|
+
* BUDGET: `REFERENCE_SCAN_VERIFY_RETRIES` per candidate and the caller's
|
|
4301
|
+
* `deadline` for the whole scan, after which it returns inconclusive. Without
|
|
4302
|
+
* both, a rate-limited RPC holds a `p-limit` slot shared with live intake for
|
|
4303
|
+
* the SDK default of 10 retries x 3s x a full window - over ten minutes for
|
|
4304
|
+
* one deferred entry.
|
|
4305
|
+
*
|
|
4306
|
+
* TERMINAL ("none") REQUIRES ALL THREE, and the caller adds two more:
|
|
4307
|
+
* 1. the listing SUCCEEDED and came back SHORT of the window, so this is the
|
|
4308
|
+
* reference's whole history rather than a truncated page. A flood that
|
|
4309
|
+
* fills the window could be hiding the payment behind it, which is
|
|
4310
|
+
* exactly how an attacker would manufacture a "nobody paid" verdict;
|
|
4311
|
+
* 2. NO candidate failed to verify. A candidate that did not verify is not
|
|
4312
|
+
* evidence: the SDK reports "this is not a payment for this request" and
|
|
4313
|
+
* "I could not reach the chain for this transaction" as the same
|
|
4314
|
+
* `{verified: false, error}`, and an unreachable RPC must never be
|
|
4315
|
+
* evidence of non-payment;
|
|
4316
|
+
* 3. no candidate was SKIPPED for belonging to another job. A skip means
|
|
4317
|
+
* something did touch this reference and we chose not to look at it, so
|
|
4318
|
+
* the scan saw less than the whole truth.
|
|
4319
|
+
* The caller then requires the payment request's own expiry to have passed and
|
|
4320
|
+
* a second consecutive sighting. Anything else is inconclusive: a false
|
|
4321
|
+
* deferral costs latency, a false "unpaid" destroys the customer's money.
|
|
4322
|
+
*
|
|
4323
|
+
* What "none" therefore means is narrow and honest: after the failed-on-chain
|
|
4324
|
+
* transactions are dropped, the reference's entire history is EMPTY - there
|
|
4325
|
+
* was nothing at all to look at.
|
|
4326
|
+
*/
|
|
4327
|
+
async verifyByReferenceScan(reference, rpc, jobId, deadline, verifySignature) {
|
|
4328
|
+
const listed = await this.listReferenceCandidates(reference, rpc, deadline);
|
|
4329
|
+
if ("error" in listed) {
|
|
4330
|
+
return { outcome: "inconclusive", error: listed.error };
|
|
4331
|
+
}
|
|
4332
|
+
let skippedConsumed = false;
|
|
4333
|
+
let unverifiableCandidate;
|
|
4334
|
+
for (const candidate of listed.signatures) {
|
|
4335
|
+
if (Date.now() > deadline) {
|
|
4336
|
+
return { outcome: "inconclusive", error: "the reference scan ran out of time" };
|
|
4337
|
+
}
|
|
4338
|
+
const owner = this.ledger.paymentSignatureOwner(candidate);
|
|
4339
|
+
if (owner !== void 0 && owner !== jobId) {
|
|
4340
|
+
skippedConsumed = true;
|
|
4341
|
+
continue;
|
|
4342
|
+
}
|
|
4343
|
+
const verified = await verifySignature(candidate, REFERENCE_SCAN_VERIFY_RETRIES);
|
|
4344
|
+
if (verified.verified) {
|
|
4345
|
+
return { outcome: "verified", txSignature: candidate };
|
|
4346
|
+
}
|
|
4347
|
+
unverifiableCandidate ??= verified.error ?? "no reason given";
|
|
4348
|
+
}
|
|
4349
|
+
if (listed.windowFull) {
|
|
4350
|
+
return {
|
|
4351
|
+
outcome: "inconclusive",
|
|
4352
|
+
error: `the reference carries at least ${REFERENCE_SCAN_WINDOW} transactions, so its history is truncated and a payment could be hidden behind the flood`
|
|
4353
|
+
};
|
|
4354
|
+
}
|
|
4355
|
+
if (skippedConsumed) {
|
|
4356
|
+
return {
|
|
4357
|
+
outcome: "inconclusive",
|
|
4358
|
+
error: "the reference carries a transaction another job already settled, so this scan did not see the whole picture"
|
|
4359
|
+
};
|
|
4360
|
+
}
|
|
4361
|
+
if (unverifiableCandidate !== void 0) {
|
|
4362
|
+
return {
|
|
4363
|
+
outcome: "inconclusive",
|
|
4364
|
+
error: `the reference carries a transaction that did not verify as a payment for this job (${unverifiableCandidate}) - indistinguishable, in what the verifier reports, from one we simply failed to read`
|
|
4365
|
+
};
|
|
4366
|
+
}
|
|
4367
|
+
return { outcome: "none" };
|
|
4368
|
+
}
|
|
4369
|
+
/**
|
|
4370
|
+
* Re-verify an on-chain payment during crash recovery.
|
|
4371
|
+
*
|
|
4372
|
+
* Two outcomes are terminal, and they mean opposite things. `corrupt-state` is
|
|
4373
|
+
* OUR state being unusable - no amount of waiting repairs it, so the caller
|
|
4374
|
+
* closes the job at once rather than hold it for 24h. `no-payment` is a scan
|
|
4375
|
+
* that saw the reference's WHOLE history and found nothing capable of being a
|
|
4376
|
+
* payment; even that only fails the job once the request's own expiry has
|
|
4377
|
+
* passed AND on a second consecutive sighting (the caller's check), because
|
|
4378
|
+
* one empty listing from a throttling RPC is not worth a customer's money.
|
|
4379
|
+
* Everything else defers: recovery cannot tell a non-paying customer apart
|
|
4380
|
+
* from a shutdown abort, a flaky RPC, or a stranger's transaction carrying
|
|
4381
|
+
* this job's (publicly readable) reference. The 24h `MAX_PAID_AGE_MS` cutoff
|
|
4382
|
+
* bounds the wait and closes the job as "the agent did not recover", never as
|
|
4383
|
+
* a false "the customer did not pay".
|
|
4384
|
+
*
|
|
4385
|
+
* Evidence order matters: a job that already CLAIMED a settlement is
|
|
4386
|
+
* re-verified against that exact signature first, and an empty scan can never
|
|
4387
|
+
* outrank it - the ledger is better evidence than an RPC that has aged the
|
|
4388
|
+
* transaction out of its history window.
|
|
4389
|
+
*
|
|
4390
|
+
* Limitation: Solana transaction data expires after ~2-3 days (recent blockhash
|
|
4391
|
+
* window). If the agent was down longer, a confirmed payment may not be found
|
|
4392
|
+
* on-chain. For mainnet: use monitoring, avoid extended downtime, or configure
|
|
4393
|
+
* an archive RPC via SOLANA_RPC_URL.
|
|
4394
|
+
*/
|
|
4395
|
+
async reVerifyPayment(entry, paymentRequestJson, priceSubunits, log, signal) {
|
|
4396
|
+
const shortId = entry.job_id.slice(0, 8);
|
|
4397
|
+
let request;
|
|
4398
|
+
let reference;
|
|
4399
|
+
try {
|
|
4400
|
+
request = JSON.parse(paymentRequestJson);
|
|
4401
|
+
reference = address(request.reference);
|
|
4402
|
+
} catch (e) {
|
|
4403
|
+
log(
|
|
4404
|
+
`[${shortId}] Recovery: the persisted payment request is unusable (${e?.message ?? "unknown error"}) - this is provider-side state, not a chain or customer problem.`
|
|
4405
|
+
);
|
|
4406
|
+
return "corrupt-state";
|
|
4407
|
+
}
|
|
4408
|
+
const deadline = Date.now() + REFERENCE_SCAN_DEADLINE_MS;
|
|
4409
|
+
try {
|
|
4410
|
+
const rpc = createSolanaRpc(getRpcUrl(this.network));
|
|
4411
|
+
const protocolConfig = await this.fetchProtocolConfig();
|
|
4412
|
+
const verify = async (txSignature2, retries) => {
|
|
4413
|
+
const verification = this.strategy.verifyPayment(rpc, request, protocolConfig, {
|
|
4414
|
+
txSignature: txSignature2,
|
|
4415
|
+
retries
|
|
4416
|
+
});
|
|
4417
|
+
if (!signal) {
|
|
4418
|
+
return verification;
|
|
4419
|
+
}
|
|
4420
|
+
let abortHandler;
|
|
4421
|
+
const abortPromise = new Promise((_, reject) => {
|
|
4422
|
+
abortHandler = () => {
|
|
4423
|
+
const err = new Error("The operation was aborted");
|
|
4424
|
+
err.name = "AbortError";
|
|
4425
|
+
reject(err);
|
|
4426
|
+
};
|
|
4427
|
+
if (signal.aborted) {
|
|
4428
|
+
abortHandler();
|
|
4429
|
+
return;
|
|
4430
|
+
}
|
|
4431
|
+
signal.addEventListener("abort", abortHandler, { once: true });
|
|
4432
|
+
});
|
|
4433
|
+
try {
|
|
4434
|
+
return await Promise.race([verification, abortPromise]);
|
|
4435
|
+
} finally {
|
|
4436
|
+
if (abortHandler) {
|
|
4437
|
+
signal.removeEventListener("abort", abortHandler);
|
|
4438
|
+
}
|
|
4439
|
+
}
|
|
4440
|
+
};
|
|
4441
|
+
const ownSignature = entry.payment_signature;
|
|
4442
|
+
let txSignature;
|
|
4443
|
+
if (ownSignature !== void 0) {
|
|
4444
|
+
const own = await verify(ownSignature, OWN_SETTLEMENT_VERIFY_RETRIES);
|
|
4445
|
+
if (own.verified) {
|
|
4446
|
+
txSignature = ownSignature;
|
|
4447
|
+
} else {
|
|
4448
|
+
log(
|
|
4449
|
+
`[${shortId}] Recovery: the settlement this job already claimed (${ownSignature}) no longer verifies (${own.error ?? "unknown"}); falling back to the reference scan.`
|
|
4450
|
+
);
|
|
4451
|
+
}
|
|
4452
|
+
}
|
|
4453
|
+
if (txSignature === void 0) {
|
|
4454
|
+
const scan = await this.verifyByReferenceScan(
|
|
4455
|
+
reference,
|
|
4456
|
+
rpc,
|
|
4457
|
+
entry.job_id,
|
|
4458
|
+
deadline,
|
|
4459
|
+
verify
|
|
4460
|
+
);
|
|
4461
|
+
if (scan.outcome === "none") {
|
|
4462
|
+
if (ownSignature !== void 0) {
|
|
4463
|
+
log(
|
|
4464
|
+
`[${shortId}] Recovery: the reference scan is empty, but this job already owns settlement ${ownSignature} - treating the empty scan as an RPC history gap, not as non-payment. Deferring.`
|
|
4465
|
+
);
|
|
4466
|
+
return "deferred";
|
|
4467
|
+
}
|
|
4468
|
+
const notBefore = terminalVerdictNotBefore(request, entry.created_at);
|
|
4469
|
+
if (Date.now() < notBefore) {
|
|
4470
|
+
const secondsLeft = Math.ceil((notBefore - Date.now()) / 1e3);
|
|
4471
|
+
log(
|
|
4472
|
+
`[${shortId}] Recovery: the reference is still empty, but the payment request this provider issued does not expire for another ${secondsLeft}s - a customer is entitled to the whole window. Waiting out the window.`
|
|
4473
|
+
);
|
|
4474
|
+
return "awaiting-window";
|
|
4475
|
+
}
|
|
4476
|
+
if (!await this.endpointMayIssueTerminalVerdict(rpc, log)) {
|
|
4477
|
+
log(
|
|
4478
|
+
`[${shortId}] Recovery: the reference scan is empty, but this RPC endpoint has not proved it is the ${this.network} cluster and serves address history - refusing to call that non-payment. Deferring.`
|
|
4479
|
+
);
|
|
4480
|
+
return "deferred";
|
|
4481
|
+
}
|
|
4482
|
+
log(
|
|
4483
|
+
`[${shortId}] Recovery: a complete scan of this reference, after the payment request expired, found no payment for this job.`
|
|
4484
|
+
);
|
|
4485
|
+
return "no-payment";
|
|
4486
|
+
}
|
|
4487
|
+
if (scan.outcome === "inconclusive") {
|
|
4488
|
+
log(
|
|
4489
|
+
`[${shortId}] Recovery: payment could not be confirmed (${scan.error}); deferring to the next tick.`
|
|
4490
|
+
);
|
|
4491
|
+
return "deferred";
|
|
4492
|
+
}
|
|
4493
|
+
txSignature = scan.txSignature;
|
|
4494
|
+
}
|
|
4495
|
+
const claim = this.claimSettlementSignature(
|
|
4496
|
+
txSignature,
|
|
4497
|
+
{ jobId: entry.job_id, customerId: entry.customer_id },
|
|
4498
|
+
log
|
|
4499
|
+
);
|
|
4500
|
+
if (claim !== "claimed") {
|
|
4501
|
+
log(
|
|
4502
|
+
`[${shortId}] Recovery: ${claimRefusalSentence(claim)}; deferring. The job stays paid and the next tick looks again.`
|
|
4503
|
+
);
|
|
4504
|
+
return "deferred";
|
|
4505
|
+
}
|
|
4506
|
+
const fee = calculateProtocolFee(priceSubunits, protocolConfig.feeBps);
|
|
4507
|
+
const netAmount = priceSubunits - fee;
|
|
4508
|
+
this.ledger.updatePayment(entry.job_id, netAmount);
|
|
4509
|
+
log(`[${shortId}] Recovery: payment re-verified (${netAmount} subunits)`);
|
|
4510
|
+
return "verified";
|
|
4511
|
+
} catch (e) {
|
|
4512
|
+
log(`[${shortId}] Recovery: payment re-verification error: ${e.message}; deferring.`);
|
|
4513
|
+
return "deferred";
|
|
4514
|
+
}
|
|
4515
|
+
}
|
|
4516
|
+
};
|
|
4517
|
+
function terminalVerdictNotBefore(request, entryCreatedAt) {
|
|
4518
|
+
const requestCreatedAt = Number(request.created_at);
|
|
4519
|
+
const expirySecs = Number(request.expiry_secs);
|
|
4520
|
+
const createdAt = Number.isFinite(requestCreatedAt) && requestCreatedAt > 0 ? requestCreatedAt : entryCreatedAt;
|
|
4521
|
+
const window = Number.isFinite(expirySecs) && expirySecs > 0 ? expirySecs : DEFAULTS.PAYMENT_EXPIRY_SECS;
|
|
4522
|
+
return (createdAt + window) * 1e3;
|
|
4523
|
+
}
|
|
3703
4524
|
var SESSIONS_DIR_NAME = ".sessions";
|
|
3704
4525
|
var SESSION_MAX_CONCURRENT_JOBS = 2;
|
|
3705
4526
|
var SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1e3;
|
|
@@ -4468,8 +5289,6 @@ var X402PermanentError = class extends Error {
|
|
|
4468
5289
|
// src/runtime.ts
|
|
4469
5290
|
var payment = new SolanaPaymentStrategy();
|
|
4470
5291
|
var LEDGER_GC_INTERVAL_MS = 60 * 60 * 1e3;
|
|
4471
|
-
var LEDGER_RETENTION_MS = 30 * 24 * 60 * 60 * 1e3;
|
|
4472
|
-
var MAX_PAID_AGE_MS = 24 * 60 * 60 * 1e3;
|
|
4473
5292
|
var SIG_PATH_TIMEOUT_MS = 60 * 1e3;
|
|
4474
5293
|
var SESSION_SUMMARIZE_SYSTEM = "Summarize the following conversation between a user and an assistant so the conversation can continue with your summary standing in for the older turns. Preserve facts, decisions, names, numbers, constraints, and open questions. Reply with the summary only.";
|
|
4475
5294
|
function buildUserRecord(data, fileName) {
|
|
@@ -4502,24 +5321,106 @@ function resolveJobAsset(tags, skills) {
|
|
|
4502
5321
|
const skill = skills.route(tags);
|
|
4503
5322
|
return skill?.asset ?? NATIVE_SOL;
|
|
4504
5323
|
}
|
|
4505
|
-
var BILLING_BODY_MARKERS3 = ["credit balance", "billing", "insufficient", "insufficient_quota"];
|
|
4506
5324
|
var SCRIPT_BILLING_INVALID_MARKERS = [
|
|
4507
|
-
|
|
4508
|
-
|
|
4509
|
-
|
|
4510
|
-
|
|
4511
|
-
|
|
4512
|
-
|
|
4513
|
-
|
|
4514
|
-
|
|
4515
|
-
|
|
4516
|
-
|
|
5325
|
+
// Three questions, one row each, so an edit cannot land half-applied: does
|
|
5326
|
+
// this phrase gate THIS pair at all, does it also take every model on the
|
|
5327
|
+
// operator's key offline (`cascades`), and is it about money rather than
|
|
5328
|
+
// credentials (`billing`, which picks the reason the operator is shown and
|
|
5329
|
+
// the recovery probe reads).
|
|
5330
|
+
//
|
|
5331
|
+
// `cascades` is the expensive one. Gating ONE pair on a false positive costs
|
|
5332
|
+
// the operator a capability until the recovery probe clears it; a cascade
|
|
5333
|
+
// costs them every capability on that key. So it needs a phrase an unrelated
|
|
5334
|
+
// failure does not produce: `insufficient` alone is a Solana builder saying
|
|
5335
|
+
// "insufficient funds for rent" and `billing` alone is a form field, while
|
|
5336
|
+
// nothing prints `invalid x-api-key` or `credit balance` except the provider
|
|
5337
|
+
// whose key it is. The bare status words carry `cascades: false` for the same
|
|
5338
|
+
// reason - `unauthorized` is what any proxy says when it did not like a
|
|
5339
|
+
// request. The LLM path next door demands an HTTP 401/402 before cascading;
|
|
5340
|
+
// this is the script path's version of that bar.
|
|
5341
|
+
{ phrase: "credit balance", cascades: true, billing: true },
|
|
5342
|
+
{ phrase: "billing", cascades: false, billing: true },
|
|
5343
|
+
{ phrase: "insufficient", cascades: false, billing: true },
|
|
5344
|
+
{ phrase: "insufficient_quota", cascades: true, billing: true },
|
|
5345
|
+
// Gates this pair, never cascades: `x-api-key` is the name of a REQUEST
|
|
5346
|
+
// HEADER, which `curl -v` and any client that prints its own headers emit on a
|
|
5347
|
+
// perfectly ordinary failure. Taking every model on the key offline for that
|
|
5348
|
+
// is the expensive direction; `invalid x-api-key` below is the provider
|
|
5349
|
+
// actually rejecting it.
|
|
5350
|
+
{ phrase: "x-api-key", cascades: false, billing: false },
|
|
5351
|
+
// The provider REJECTING the key, which no request header says.
|
|
5352
|
+
{ phrase: "invalid x-api-key", cascades: true, billing: false },
|
|
5353
|
+
{ phrase: "invalid api key", cascades: true, billing: false },
|
|
5354
|
+
{ phrase: "invalid_api_key", cascades: true, billing: false },
|
|
5355
|
+
{ phrase: "authentication_error", cascades: true, billing: false },
|
|
5356
|
+
{ phrase: "unauthorized", cascades: false, billing: false },
|
|
5357
|
+
{ phrase: "unauthenticated", cascades: false, billing: false }
|
|
4517
5358
|
];
|
|
4518
|
-
|
|
4519
|
-
|
|
4520
|
-
|
|
5359
|
+
var BILLING_BODY_MARKERS3 = SCRIPT_BILLING_INVALID_MARKERS.filter((marker) => marker.billing).map(
|
|
5360
|
+
(marker) => marker.phrase
|
|
5361
|
+
);
|
|
5362
|
+
function classifyScriptSignal(message) {
|
|
5363
|
+
let looksBillingOrInvalid = false;
|
|
5364
|
+
let billing = false;
|
|
5365
|
+
let cascade = false;
|
|
5366
|
+
let signalAt = -1;
|
|
5367
|
+
for (const match of message.matchAll(ALL_MARKERS_RE)) {
|
|
5368
|
+
const marker = MARKER_BY_PHRASE.get(match[0].toLowerCase());
|
|
5369
|
+
looksBillingOrInvalid = true;
|
|
5370
|
+
billing = billing || marker?.billing === true;
|
|
5371
|
+
cascade = cascade || marker?.cascades === true;
|
|
5372
|
+
if (signalAt === -1) {
|
|
5373
|
+
signalAt = match.index;
|
|
5374
|
+
}
|
|
5375
|
+
}
|
|
5376
|
+
return { looksBillingOrInvalid, reason: billing ? "billing" : "invalid", cascade, signalAt };
|
|
5377
|
+
}
|
|
5378
|
+
var HOST_DISK_CODES = /* @__PURE__ */ new Set(["ENOSPC", "EROFS", "EDQUOT", "EMFILE", "ENFILE", "EIO"]);
|
|
5379
|
+
function asHostScratchFailure(error) {
|
|
5380
|
+
const code = error?.code;
|
|
5381
|
+
if (code === void 0 || !HOST_DISK_CODES.has(code)) {
|
|
5382
|
+
return void 0;
|
|
5383
|
+
}
|
|
5384
|
+
return new HostScratchError(error instanceof Error ? error.message : String(error));
|
|
5385
|
+
}
|
|
5386
|
+
var HEALTH_REASON_CHARS = 500;
|
|
5387
|
+
var SIGNAL_LEAD_UNITS = 80;
|
|
5388
|
+
function markerPattern(phrases, flags) {
|
|
5389
|
+
const longestFirst = [...phrases].sort((left, right) => right.length - left.length);
|
|
5390
|
+
return new RegExp(
|
|
5391
|
+
longestFirst.map((phrase) => phrase.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|"),
|
|
5392
|
+
flags
|
|
5393
|
+
);
|
|
5394
|
+
}
|
|
5395
|
+
var ALL_MARKERS_RE = markerPattern(
|
|
5396
|
+
SCRIPT_BILLING_INVALID_MARKERS.map((marker) => marker.phrase),
|
|
5397
|
+
"gi"
|
|
5398
|
+
);
|
|
5399
|
+
var MARKER_BY_PHRASE = new Map(
|
|
5400
|
+
SCRIPT_BILLING_INVALID_MARKERS.map((marker) => [marker.phrase, marker])
|
|
5401
|
+
);
|
|
5402
|
+
function signalIndex(text) {
|
|
5403
|
+
ALL_MARKERS_RE.lastIndex = 0;
|
|
5404
|
+
const found = ALL_MARKERS_RE.exec(text)?.index ?? -1;
|
|
5405
|
+
ALL_MARKERS_RE.lastIndex = 0;
|
|
5406
|
+
return found;
|
|
5407
|
+
}
|
|
5408
|
+
function operatorReason(diagnostic, known, budget = HEALTH_REASON_CHARS) {
|
|
5409
|
+
if (startsWithRefusalHint(diagnostic)) {
|
|
5410
|
+
return excerptUntrusted(diagnostic, budget);
|
|
5411
|
+
}
|
|
5412
|
+
const signalAt = known ?? signalIndex(diagnostic);
|
|
5413
|
+
if (signalAt === -1) {
|
|
5414
|
+
return excerptUntrustedTail(diagnostic, budget);
|
|
5415
|
+
}
|
|
5416
|
+
const from = Math.max(0, signalAt - SIGNAL_LEAD_UNITS);
|
|
5417
|
+
if (from === 0) {
|
|
5418
|
+
return excerptUntrusted(diagnostic, budget);
|
|
5419
|
+
}
|
|
5420
|
+
return `\u2026${excerptUntrusted(diagnostic.slice(from, from + budget * 8), budget - 1)}`;
|
|
4521
5421
|
}
|
|
4522
5422
|
var AGENT_UNAVAILABLE_MESSAGE = "Agent temporarily unavailable";
|
|
5423
|
+
var OPERATOR_EXCERPT_CHARS = 1500;
|
|
4523
5424
|
var AgentUnavailableError = class extends Error {
|
|
4524
5425
|
constructor() {
|
|
4525
5426
|
super(AGENT_UNAVAILABLE_MESSAGE);
|
|
@@ -4538,9 +5439,13 @@ var SeedFailedError = class extends Error {
|
|
|
4538
5439
|
this.name = "SeedFailedError";
|
|
4539
5440
|
}
|
|
4540
5441
|
};
|
|
5442
|
+
var RECOVERY_NO_PAYMENT_CUSTOMER_MESSAGE = "Job permanently failed: no payment for this job was found on-chain after the payment request expired.";
|
|
5443
|
+
var RECOVERY_UNVERIFIABLE_CUSTOMER_MESSAGE = "Job permanently failed: the provider could not verify payment for this job. If you paid, contact the provider with your transaction signature.";
|
|
5444
|
+
var RECOVERY_NO_SKILL_CUSTOMER_MESSAGE = "Job permanently failed: this provider no longer offers a skill for this job. If you paid, contact the provider with your transaction signature.";
|
|
5445
|
+
var RECOVERY_INPUT_UNAVAILABLE_CUSTOMER_MESSAGE = "Job permanently failed: the input file for this job could not be retrieved after an agent restart - contact the provider to resolve.";
|
|
4541
5446
|
var PaymentTimeoutError = class extends Error {
|
|
4542
5447
|
constructor() {
|
|
4543
|
-
super("Payment
|
|
5448
|
+
super("Payment timeout: no payment received before the deadline.");
|
|
4544
5449
|
this.name = "PaymentTimeoutError";
|
|
4545
5450
|
}
|
|
4546
5451
|
};
|
|
@@ -4569,16 +5474,60 @@ function customerSafeMessage(error) {
|
|
|
4569
5474
|
if (error instanceof AgentUnavailableError || error instanceof ExecutionBudgetExceededError || error instanceof SeedFailedError) {
|
|
4570
5475
|
return error.message;
|
|
4571
5476
|
}
|
|
4572
|
-
if (error
|
|
4573
|
-
return error.message
|
|
5477
|
+
if (isScriptRefusalError(error)) {
|
|
5478
|
+
return `${PROVIDER_REFUSED_PREFIX}${error.message}`;
|
|
5479
|
+
}
|
|
5480
|
+
if (isScriptExecutionError(error)) {
|
|
5481
|
+
return PROVIDER_FAILED_MESSAGE;
|
|
4574
5482
|
}
|
|
4575
|
-
if (error instanceof
|
|
5483
|
+
if (isScriptBillingExhaustedError(error) || isHostScratchError(error) || error instanceof X402TransientError) {
|
|
4576
5484
|
return AGENT_UNAVAILABLE_MESSAGE;
|
|
4577
5485
|
}
|
|
4578
5486
|
if (error instanceof Error && CUSTOMER_SAFE_MESSAGE_PREFIXES.some((prefix) => error.message.startsWith(prefix))) {
|
|
4579
5487
|
return error.message;
|
|
4580
5488
|
}
|
|
4581
|
-
return
|
|
5489
|
+
return PROVIDER_FAILED_MESSAGE;
|
|
5490
|
+
}
|
|
5491
|
+
function needsScratchSpace(mode, hasAttachment) {
|
|
5492
|
+
if (hasAttachment) {
|
|
5493
|
+
return true;
|
|
5494
|
+
}
|
|
5495
|
+
return mode !== void 0 && mode !== "llm" && mode !== "static-file" && mode !== "static-script";
|
|
5496
|
+
}
|
|
5497
|
+
function describeForOperator(error) {
|
|
5498
|
+
if (isHostScratchError(error)) {
|
|
5499
|
+
return `${error.message}: ${excerptUntrusted(error.detail, OPERATOR_EXCERPT_CHARS)}`;
|
|
5500
|
+
}
|
|
5501
|
+
if (isScriptBillingExhaustedError(error)) {
|
|
5502
|
+
return `script signalled billing exhausted: ${excerptUntrustedTail(scriptOutput(error), OPERATOR_EXCERPT_CHARS)}`;
|
|
5503
|
+
}
|
|
5504
|
+
if (isScriptRefusalError(error)) {
|
|
5505
|
+
return error.stderr === "" ? `refused: ${error.message}` : `refused: ${error.message} (stderr: ${error.stderr})`;
|
|
5506
|
+
}
|
|
5507
|
+
if (isScriptExecutionError(error)) {
|
|
5508
|
+
return `${error.message}: ${excerptUntrustedTail(error.detail, OPERATOR_EXCERPT_CHARS)}`;
|
|
5509
|
+
}
|
|
5510
|
+
if (typeof error === "string") {
|
|
5511
|
+
return excerptUntrusted(error, OPERATOR_EXCERPT_CHARS);
|
|
5512
|
+
}
|
|
5513
|
+
if (typeof error === "object" && error !== null && "message" in error) {
|
|
5514
|
+
const message = error.message;
|
|
5515
|
+
if (typeof message === "string" && message !== "") {
|
|
5516
|
+
return excerptUntrusted(message, OPERATOR_EXCERPT_CHARS);
|
|
5517
|
+
}
|
|
5518
|
+
}
|
|
5519
|
+
return "Unknown error";
|
|
5520
|
+
}
|
|
5521
|
+
function parseRawEventDelegated(rawEventJson) {
|
|
5522
|
+
if (rawEventJson === void 0) {
|
|
5523
|
+
return false;
|
|
5524
|
+
}
|
|
5525
|
+
try {
|
|
5526
|
+
const raw = JSON.parse(rawEventJson);
|
|
5527
|
+
return Array.isArray(raw.tags) && raw.tags.some((tag) => Array.isArray(tag) && tag[0] === "payment" && tag[1] === "delegated");
|
|
5528
|
+
} catch {
|
|
5529
|
+
return false;
|
|
5530
|
+
}
|
|
4582
5531
|
}
|
|
4583
5532
|
function bodyLooksLikeBilling3(body) {
|
|
4584
5533
|
const lower = body.toLowerCase();
|
|
@@ -4627,6 +5576,12 @@ var AgentRuntime = class {
|
|
|
4627
5576
|
this.nonceStore = nonceStore;
|
|
4628
5577
|
this.limit = pLimit(config.maxConcurrentJobs);
|
|
4629
5578
|
this.maxQueueSize = config.maxQueueSize ?? config.maxConcurrentJobs * 10;
|
|
5579
|
+
this.paymentRecovery = new PaymentRecovery(
|
|
5580
|
+
ledger,
|
|
5581
|
+
config.network,
|
|
5582
|
+
() => this.fetchProtocolConfig(),
|
|
5583
|
+
payment
|
|
5584
|
+
);
|
|
4630
5585
|
}
|
|
4631
5586
|
limit;
|
|
4632
5587
|
inFlight = /* @__PURE__ */ new Set();
|
|
@@ -4637,6 +5592,27 @@ var AgentRuntime = class {
|
|
|
4637
5592
|
recoveryInterval = null;
|
|
4638
5593
|
gcInterval = null;
|
|
4639
5594
|
stopped = false;
|
|
5595
|
+
/**
|
|
5596
|
+
* Backoff bookkeeping for `paid` entries whose payment recovery could not
|
|
5597
|
+
* conclude. Public so an operator-facing surface (and the tests) can read the
|
|
5598
|
+
* backlog without reaching into the runtime's internals.
|
|
5599
|
+
*/
|
|
5600
|
+
recoveryDeferrals = new RecoveryDeferrals(() => this.config.recoveryIntervalSecs);
|
|
5601
|
+
/** Settlement binding and payment re-verification - see `payment-recovery.ts`. */
|
|
5602
|
+
paymentRecovery;
|
|
5603
|
+
/**
|
|
5604
|
+
* Size of the deferral backlog at the end of the previous tick, so the
|
|
5605
|
+
* transition back to zero is logged exactly once. Without it the summary goes
|
|
5606
|
+
* quiet on the tick the backlog clears and an operator never sees it clear -
|
|
5607
|
+
* the one line they were waiting for.
|
|
5608
|
+
*/
|
|
5609
|
+
lastDeferralBacklog = 0;
|
|
5610
|
+
/**
|
|
5611
|
+
* Memoised `raw_event_json` delegated discriminator, keyed on the ledger entry
|
|
5612
|
+
* OBJECT - see {@link rawEventIsDelegated}. Weak so it is bounded by the
|
|
5613
|
+
* entries the ledger is holding, not by everything this process has ever seen.
|
|
5614
|
+
*/
|
|
5615
|
+
rawEventDelegatedCache = /* @__PURE__ */ new WeakMap();
|
|
4640
5616
|
/** Per-customer sliding-window rate limiter for free skills. */
|
|
4641
5617
|
freeCustomerLimiter = createSlidingWindowLimiter({
|
|
4642
5618
|
windowMs: RATE_LIMIT_WINDOW_MS,
|
|
@@ -4827,12 +5803,51 @@ var AgentRuntime = class {
|
|
|
4827
5803
|
}
|
|
4828
5804
|
return ` (cascading to ${siblings} other model(s) for ${provider} sharing the same API key)`;
|
|
4829
5805
|
}
|
|
5806
|
+
/**
|
|
5807
|
+
* Whether this agent can still make itself a scratch directory.
|
|
5808
|
+
*
|
|
5809
|
+
* A `HostScratchError` says the last job could not run because the temp
|
|
5810
|
+
* directory refused it. That is not the operator's API key, so it must not
|
|
5811
|
+
* gate the health pair - but SOMETHING has to stop the next customer paying
|
|
5812
|
+
* into an agent that will fail them the same way, which is what the health
|
|
5813
|
+
* gate used to do by accident. Probed before payment, and only once such a
|
|
5814
|
+
* failure has been seen - a working host pays nothing for this, and a broken
|
|
5815
|
+
* one is asked again on every job rather than after a timer: an agent whose
|
|
5816
|
+
* jobs arrive minutes apart would otherwise let every customer through.
|
|
5817
|
+
*/
|
|
5818
|
+
scratchFailed = false;
|
|
5819
|
+
async scratchSpaceUsable() {
|
|
5820
|
+
if (!this.scratchFailed) {
|
|
5821
|
+
return true;
|
|
5822
|
+
}
|
|
5823
|
+
const probe = await mkdtemp(join(tmpdir(), "elisym-probe-")).catch(() => null);
|
|
5824
|
+
if (probe === null) {
|
|
5825
|
+
return false;
|
|
5826
|
+
}
|
|
5827
|
+
const wrote = await writeFile(join(probe, "probe"), "x").then(() => true).catch(() => false);
|
|
5828
|
+
await rm(probe, { recursive: true, force: true }).catch(() => {
|
|
5829
|
+
});
|
|
5830
|
+
if (!wrote) {
|
|
5831
|
+
return false;
|
|
5832
|
+
}
|
|
5833
|
+
this.scratchFailed = false;
|
|
5834
|
+
return true;
|
|
5835
|
+
}
|
|
4830
5836
|
markHealthFromExecuteError(skill, err, log, jobId) {
|
|
4831
5837
|
if (!this.healthMonitor) {
|
|
4832
5838
|
return false;
|
|
4833
5839
|
}
|
|
4834
5840
|
const tag = `[${jobId.slice(0, 8)}]`;
|
|
4835
|
-
if (err
|
|
5841
|
+
if (isHostScratchError(err)) {
|
|
5842
|
+
log(
|
|
5843
|
+
`${tag} Skill "${skill.name}" could not be given scratch space by THIS AGENT: ${excerptUntrusted(err.detail, 300)} Health state unchanged; the job stays paid for recovery.`
|
|
5844
|
+
);
|
|
5845
|
+
return false;
|
|
5846
|
+
}
|
|
5847
|
+
if (isScriptRefusalError(err)) {
|
|
5848
|
+
return false;
|
|
5849
|
+
}
|
|
5850
|
+
if (isScriptBillingExhaustedError(err)) {
|
|
4836
5851
|
const provider = skill.llmOverride?.provider;
|
|
4837
5852
|
const model = skill.llmOverride?.model;
|
|
4838
5853
|
if (!provider || !model) {
|
|
@@ -4844,7 +5859,12 @@ var AgentRuntime = class {
|
|
|
4844
5859
|
log(
|
|
4845
5860
|
`${tag} Script signaled billing-exhausted (exit ${err.exitCode}). Marking ${provider}/${model} unhealthy${this.cascadeSuffix(provider, model)}; future jobs against this pair will be refused until recovery probe succeeds.`
|
|
4846
5861
|
);
|
|
4847
|
-
this.healthMonitor.markUnhealthyFromJob(
|
|
5862
|
+
this.healthMonitor.markUnhealthyFromJob(
|
|
5863
|
+
provider,
|
|
5864
|
+
model,
|
|
5865
|
+
"billing",
|
|
5866
|
+
excerptUntrustedTail(scriptOutput(err), 200)
|
|
5867
|
+
);
|
|
4848
5868
|
return true;
|
|
4849
5869
|
}
|
|
4850
5870
|
if (skill.mode === "llm" && skill.resolvedTriple) {
|
|
@@ -4854,7 +5874,7 @@ var AgentRuntime = class {
|
|
|
4854
5874
|
return false;
|
|
4855
5875
|
}
|
|
4856
5876
|
const status = Number(match[1]);
|
|
4857
|
-
const body = (match[2] ?? ""
|
|
5877
|
+
const body = excerptUntrusted(match[2] ?? "", 200);
|
|
4858
5878
|
const isBillingStatus = status === 402;
|
|
4859
5879
|
const isAuthStatus = status === 401 || status === 403;
|
|
4860
5880
|
const isBilling400 = status === 400 && bodyLooksLikeBilling3(body);
|
|
@@ -4873,33 +5893,43 @@ var AgentRuntime = class {
|
|
|
4873
5893
|
}
|
|
4874
5894
|
if (skill.mode !== "llm") {
|
|
4875
5895
|
let message;
|
|
4876
|
-
|
|
4877
|
-
|
|
5896
|
+
let scanned;
|
|
5897
|
+
let diagnostic;
|
|
5898
|
+
if (isScriptExecutionError(err)) {
|
|
5899
|
+
message = err.stderr ?? err.detail;
|
|
5900
|
+
diagnostic = startsWithRefusalHint(err.detail) || message.trim() === "" ? err.detail : message;
|
|
5901
|
+
scanned = err.stderr === void 0 ? "the skill error" : "stderr";
|
|
4878
5902
|
} else if (err instanceof Error) {
|
|
4879
5903
|
message = err.message;
|
|
5904
|
+
diagnostic = message;
|
|
5905
|
+
scanned = "the skill error";
|
|
4880
5906
|
} else {
|
|
4881
5907
|
message = String(err);
|
|
5908
|
+
diagnostic = message;
|
|
5909
|
+
scanned = "the skill error";
|
|
4882
5910
|
}
|
|
4883
5911
|
const provider = skill.llmOverride?.provider;
|
|
4884
5912
|
const model = skill.llmOverride?.model;
|
|
4885
5913
|
if (!provider || !model) {
|
|
4886
5914
|
log(
|
|
4887
|
-
`${tag} Script "${skill.name}" failed ("${
|
|
5915
|
+
`${tag} Script "${skill.name}" failed ("${operatorReason(diagnostic)}") but did not declare provider/model in SKILL.md - cannot gate future jobs.`
|
|
4888
5916
|
);
|
|
4889
5917
|
return false;
|
|
4890
5918
|
}
|
|
4891
|
-
const
|
|
4892
|
-
const looksBillingOrInvalid = scriptMessageLooksLikeBillingOrInvalid(message);
|
|
4893
|
-
const reason = looksBillingOrInvalid && (lower.includes("credit balance") || lower.includes("billing") || lower.includes("insufficient")) ? "billing" : "invalid";
|
|
4894
|
-
const cascade = looksBillingOrInvalid;
|
|
5919
|
+
const { looksBillingOrInvalid, reason, cascade, signalAt } = classifyScriptSignal(message);
|
|
4895
5920
|
const cascadeNote = cascade ? this.cascadeSuffix(provider, model) : " (no cascade)";
|
|
4896
|
-
const signalNote = looksBillingOrInvalid ? `${reason} signal in
|
|
5921
|
+
const signalNote = looksBillingOrInvalid ? `${reason} signal in ${scanned}` : `generic exit (no billing/invalid markers, classified as ${reason}, skill-local)`;
|
|
4897
5922
|
log(
|
|
4898
5923
|
`${tag} Script failure (${signalNote}). Marking ${provider}/${model} unhealthy${cascadeNote}; future jobs against this pair will be refused until recovery probe succeeds.`
|
|
4899
5924
|
);
|
|
4900
|
-
this.healthMonitor.markUnhealthyFromJob(
|
|
4901
|
-
|
|
4902
|
-
|
|
5925
|
+
this.healthMonitor.markUnhealthyFromJob(
|
|
5926
|
+
provider,
|
|
5927
|
+
model,
|
|
5928
|
+
reason,
|
|
5929
|
+
// The index only when the quote is of the same string the scan read.
|
|
5930
|
+
operatorReason(diagnostic, diagnostic === message ? signalAt : void 0),
|
|
5931
|
+
{ cascade }
|
|
5932
|
+
);
|
|
4903
5933
|
return true;
|
|
4904
5934
|
}
|
|
4905
5935
|
return false;
|
|
@@ -5014,8 +6044,8 @@ var AgentRuntime = class {
|
|
|
5014
6044
|
if (jobSession) {
|
|
5015
6045
|
jobSession.store.admitLive(jobSession.customerId, jobSession.sessionId);
|
|
5016
6046
|
}
|
|
5017
|
-
this.limit(() => this.processJob(job)).catch((
|
|
5018
|
-
this.callbacks.onJobError?.(job.jobId,
|
|
6047
|
+
this.limit(() => this.processJob(job)).catch((err) => {
|
|
6048
|
+
this.callbacks.onJobError?.(job.jobId, describeForOperator(err));
|
|
5019
6049
|
}).finally(() => {
|
|
5020
6050
|
this.inFlight.delete(job.jobId);
|
|
5021
6051
|
this.pending--;
|
|
@@ -5100,18 +6130,31 @@ var AgentRuntime = class {
|
|
|
5100
6130
|
this.jobAbortControllers.add(jobAbort);
|
|
5101
6131
|
try {
|
|
5102
6132
|
await this.executeJob(job, jobAbort.signal);
|
|
5103
|
-
} catch (
|
|
6133
|
+
} catch (raw) {
|
|
6134
|
+
const e = asHostScratchFailure(raw) ?? raw;
|
|
5104
6135
|
const log = this.callbacks.onLog ?? console.log;
|
|
5105
|
-
|
|
6136
|
+
const operatorMessage = describeForOperator(e);
|
|
6137
|
+
log(`[${job.jobId.slice(0, 8)}] Error: ${operatorMessage}`);
|
|
6138
|
+
if (isHostScratchError(e)) {
|
|
6139
|
+
this.scratchFailed = true;
|
|
6140
|
+
}
|
|
5106
6141
|
const currentStatus = this.ledger.getStatus(job.jobId);
|
|
5107
|
-
const keepPaidForRecovery = (e instanceof AgentUnavailableError ||
|
|
6142
|
+
const keepPaidForRecovery = (e instanceof AgentUnavailableError || // The exit-42 contract: the key is out of credits, not the job out of
|
|
6143
|
+
// sense, so the job waits for the operator to top up. Reached when the
|
|
6144
|
+
// gate did NOT flip - an agent with no health monitor, or a skill that
|
|
6145
|
+
// declares no pair - where the customer is told "temporarily
|
|
6146
|
+
// unavailable" and that has to stay true of their money.
|
|
6147
|
+
isScriptBillingExhaustedError(e) || // The agent's own disk, not the job: a tmpdir that is full or
|
|
6148
|
+
// read-only now may not be in five minutes, and the customer has
|
|
6149
|
+
// already paid. Terminating here would keep their money for a failure
|
|
6150
|
+
// that was never about their request.
|
|
6151
|
+
isHostScratchError(e) || e instanceof SeedFailedError || e instanceof PaymentTimeoutError || e instanceof X402TransientError || e instanceof ExecutionBudgetExceededError && this.skills.route(job.tags)?.mode === "x402") && currentStatus === "paid";
|
|
5108
6152
|
if (currentStatus !== "executed" && !keepPaidForRecovery) {
|
|
5109
6153
|
this.ledger.markFailed(job.jobId);
|
|
5110
6154
|
}
|
|
5111
6155
|
if (keepPaidForRecovery) {
|
|
5112
6156
|
log(`[${job.jobId.slice(0, 8)}] Keeping status=paid; recovery will retry (24h cutoff).`);
|
|
5113
6157
|
}
|
|
5114
|
-
const operatorMessage = e instanceof ScriptExecutionError ? `${e.message}: ${e.detail}` : e.message ?? "Unknown error";
|
|
5115
6158
|
this.callbacks.onJobError?.(job.jobId, operatorMessage);
|
|
5116
6159
|
const safeMessage = customerSafeMessage(e);
|
|
5117
6160
|
await this.transport.sendFeedback(job, { type: "error", message: safeMessage }).catch(() => {
|
|
@@ -5143,6 +6186,14 @@ var AgentRuntime = class {
|
|
|
5143
6186
|
return;
|
|
5144
6187
|
}
|
|
5145
6188
|
}
|
|
6189
|
+
if (needsScratchSpace(matched?.mode, job.attachment !== void 0) && !await this.scratchSpaceUsable()) {
|
|
6190
|
+
log(
|
|
6191
|
+
`[${job.jobId.slice(0, 8)}] Refusing job before payment: this agent cannot create scratch space (check the temp directory).`
|
|
6192
|
+
);
|
|
6193
|
+
await this.transport.sendFeedback(job, { type: "error", message: AGENT_UNAVAILABLE_MESSAGE }).catch(() => {
|
|
6194
|
+
});
|
|
6195
|
+
return;
|
|
6196
|
+
}
|
|
5146
6197
|
if (matched?.mode === "x402" && job.attachment !== void 0) {
|
|
5147
6198
|
const method = matched.x402?.method ?? "POST";
|
|
5148
6199
|
const maxInputBytes = matched.x402?.maxInputBytes ?? 0;
|
|
@@ -5632,24 +6683,80 @@ var AgentRuntime = class {
|
|
|
5632
6683
|
}
|
|
5633
6684
|
}
|
|
5634
6685
|
}
|
|
6686
|
+
/**
|
|
6687
|
+
* Whether this tick would spend an RPC reference scan on the entry. The
|
|
6688
|
+
* delegated discriminator is the runtime's (delegated entries reconcile
|
|
6689
|
+
* through the pull path, which never reads the reference); the rest of the
|
|
6690
|
+
* predicate is `needsPaymentScan`.
|
|
6691
|
+
*/
|
|
6692
|
+
needsPaymentReVerification(entry) {
|
|
6693
|
+
return needsPaymentScan(entry, this.isDelegatedEntry(entry));
|
|
6694
|
+
}
|
|
6695
|
+
/**
|
|
6696
|
+
* Close a recovered job AND tell the customer why.
|
|
6697
|
+
*
|
|
6698
|
+
* Recovery's verdicts are the last word on a job the customer already paid
|
|
6699
|
+
* for (or believes they did), so a silent `markFailed` leaves them watching a
|
|
6700
|
+
* job that will never answer. The 24h cutoff has always sent a notice; every
|
|
6701
|
+
* other terminal recovery verdict sends one too.
|
|
6702
|
+
*/
|
|
6703
|
+
async failRecoveredJob(entry, job, message) {
|
|
6704
|
+
this.ledger.markFailed(entry.job_id);
|
|
6705
|
+
await this.transport.sendFeedback(job, { type: "error", message }).catch(() => {
|
|
6706
|
+
});
|
|
6707
|
+
}
|
|
6708
|
+
/**
|
|
6709
|
+
* One line per tick that sums up the deferral backlog, including the tick it
|
|
6710
|
+
* finally clears - an operator watching a stuck agent needs to see the end of
|
|
6711
|
+
* it, and a summary that simply stops appearing is indistinguishable from an
|
|
6712
|
+
* agent that stopped logging.
|
|
6713
|
+
*/
|
|
6714
|
+
logDeferralSummary(log) {
|
|
6715
|
+
const backlog = this.recoveryDeferrals.size;
|
|
6716
|
+
const summary = this.recoveryDeferrals.summaryLine();
|
|
6717
|
+
if (summary !== null) {
|
|
6718
|
+
log(summary);
|
|
6719
|
+
} else if (this.lastDeferralBacklog > 0) {
|
|
6720
|
+
log("Recovery: no jobs are deferred awaiting payment confirmation any more.");
|
|
6721
|
+
}
|
|
6722
|
+
this.lastDeferralBacklog = backlog;
|
|
6723
|
+
}
|
|
5635
6724
|
/**
|
|
5636
6725
|
* Delegated discriminator for recovery routing. The ledger flag is primary;
|
|
5637
6726
|
* the persisted raw event's top-level `payment` tag is the fallback for an
|
|
5638
6727
|
* entry whose flag write was lost.
|
|
6728
|
+
*
|
|
6729
|
+
* The flag is re-read every call - `markDelegated` can set it on an entry a
|
|
6730
|
+
* recovery tick has already looked at - while the FALLBACK is memoised, since
|
|
6731
|
+
* `raw_event_json` never changes for a given entry object.
|
|
5639
6732
|
*/
|
|
5640
6733
|
isDelegatedEntry(entry) {
|
|
5641
6734
|
if (entry.delegated === true) {
|
|
5642
6735
|
return true;
|
|
5643
6736
|
}
|
|
5644
|
-
|
|
5645
|
-
|
|
5646
|
-
|
|
5647
|
-
|
|
5648
|
-
|
|
5649
|
-
|
|
5650
|
-
|
|
5651
|
-
|
|
6737
|
+
return this.rawEventIsDelegated(entry);
|
|
6738
|
+
}
|
|
6739
|
+
/**
|
|
6740
|
+
* The `raw_event_json` half of {@link isDelegatedEntry}, memoised per entry
|
|
6741
|
+
* OBJECT.
|
|
6742
|
+
*
|
|
6743
|
+
* The per-tick scan budget asks this of every non-deferred pending entry, and
|
|
6744
|
+
* an entry over budget is parked and asked again next tick, so an uncached
|
|
6745
|
+
* version re-parses every pending job's whole payload once a minute for as
|
|
6746
|
+
* long as the backlog lasts - a full JSON parse of the customer's input (up to
|
|
6747
|
+
* the 64KB inline cap) per entry per tick, to read one tag. The persisted
|
|
6748
|
+
* event is immutable for the life of an entry object, so one parse is the
|
|
6749
|
+
* whole answer; a ledger reload produces fresh objects and re-parses, which is
|
|
6750
|
+
* correct.
|
|
6751
|
+
*/
|
|
6752
|
+
rawEventIsDelegated(entry) {
|
|
6753
|
+
const memoised = this.rawEventDelegatedCache.get(entry);
|
|
6754
|
+
if (memoised !== void 0) {
|
|
6755
|
+
return memoised;
|
|
5652
6756
|
}
|
|
6757
|
+
const delegated = parseRawEventDelegated(entry.raw_event_json);
|
|
6758
|
+
this.rawEventDelegatedCache.set(entry, delegated);
|
|
6759
|
+
return delegated;
|
|
5653
6760
|
}
|
|
5654
6761
|
/**
|
|
5655
6762
|
* Reconcile a delegated `paid` entry to its terminal - the FIRST action in
|
|
@@ -5973,7 +7080,9 @@ var AgentRuntime = class {
|
|
|
5973
7080
|
});
|
|
5974
7081
|
return this.materializeBytesInput(bytes, attachment.mime);
|
|
5975
7082
|
}
|
|
5976
|
-
const dir = await mkdtemp(join(tmpdir(), "elisym-job-"))
|
|
7083
|
+
const dir = await mkdtemp(join(tmpdir(), "elisym-job-")).catch((err) => {
|
|
7084
|
+
throw new HostScratchError(err instanceof Error ? err.message : String(err));
|
|
7085
|
+
});
|
|
5977
7086
|
const filePath = join(dir, "input");
|
|
5978
7087
|
try {
|
|
5979
7088
|
await this.irohTransport.fetchToPath(irohMember.ticket, filePath, {
|
|
@@ -6025,14 +7134,16 @@ var AgentRuntime = class {
|
|
|
6025
7134
|
return { inlineText: Buffer.from(bytes).toString("utf8"), cleanup: async () => {
|
|
6026
7135
|
} };
|
|
6027
7136
|
}
|
|
6028
|
-
const dir = await mkdtemp(join(tmpdir(), "elisym-job-"))
|
|
7137
|
+
const dir = await mkdtemp(join(tmpdir(), "elisym-job-")).catch((err) => {
|
|
7138
|
+
throw new HostScratchError(err instanceof Error ? err.message : String(err));
|
|
7139
|
+
});
|
|
6029
7140
|
const filePath = join(dir, "input");
|
|
6030
7141
|
try {
|
|
6031
7142
|
await writeFile(filePath, bytes);
|
|
6032
7143
|
} catch (error) {
|
|
6033
7144
|
await rm(dir, { recursive: true, force: true }).catch(() => {
|
|
6034
7145
|
});
|
|
6035
|
-
throw error;
|
|
7146
|
+
throw asHostScratchFailure(error) ?? error;
|
|
6036
7147
|
}
|
|
6037
7148
|
return {
|
|
6038
7149
|
filePath,
|
|
@@ -6085,6 +7196,7 @@ var AgentRuntime = class {
|
|
|
6085
7196
|
const sigPathTimeoutMs = Math.min(deadlineMs, SIG_PATH_TIMEOUT_MS);
|
|
6086
7197
|
let sigOutcome;
|
|
6087
7198
|
let refOutcome;
|
|
7199
|
+
let paymentRefusal;
|
|
6088
7200
|
let result;
|
|
6089
7201
|
try {
|
|
6090
7202
|
result = await new Promise((resolve4, reject) => {
|
|
@@ -6112,6 +7224,25 @@ var AgentRuntime = class {
|
|
|
6112
7224
|
resolve4(lastResult);
|
|
6113
7225
|
}
|
|
6114
7226
|
};
|
|
7227
|
+
const accept = (verified, pathLabel, askedSignature) => {
|
|
7228
|
+
if (settled) {
|
|
7229
|
+
return;
|
|
7230
|
+
}
|
|
7231
|
+
const txSignature = askedSignature ?? verified.txSignature;
|
|
7232
|
+
if (txSignature === void 0) {
|
|
7233
|
+
paymentRefusal = { consumedByOther: false, sentence: UNNAMED_SETTLEMENT_SENTENCE };
|
|
7234
|
+
lose({ verified: false }, `${pathLabel}: ${UNNAMED_SETTLEMENT_SENTENCE}`);
|
|
7235
|
+
return;
|
|
7236
|
+
}
|
|
7237
|
+
const claim = this.paymentRecovery.claimSettlementSignature(txSignature, job, log);
|
|
7238
|
+
if (claim !== "claimed") {
|
|
7239
|
+
const sentence = claimRefusalSentence(claim);
|
|
7240
|
+
paymentRefusal = { consumedByOther: claim === "consumed-by-other", sentence };
|
|
7241
|
+
lose({ verified: false }, `${pathLabel}: settlement refused - ${sentence}`);
|
|
7242
|
+
return;
|
|
7243
|
+
}
|
|
7244
|
+
win(verified);
|
|
7245
|
+
};
|
|
6115
7246
|
this.transport.waitForPaymentSignature(job.jobId, job.customerId, verifyAbort.signal, sigPathTimeoutMs).then(async (sig) => {
|
|
6116
7247
|
if (settled) {
|
|
6117
7248
|
return;
|
|
@@ -6127,7 +7258,7 @@ var AgentRuntime = class {
|
|
|
6127
7258
|
txSignature: sig
|
|
6128
7259
|
});
|
|
6129
7260
|
if (verified.verified) {
|
|
6130
|
-
|
|
7261
|
+
accept(verified, "sig path", sig);
|
|
6131
7262
|
} else {
|
|
6132
7263
|
const reason = verified.error ?? "unknown";
|
|
6133
7264
|
sigOutcome = { gotSignature: true, error: reason };
|
|
@@ -6145,7 +7276,7 @@ var AgentRuntime = class {
|
|
|
6145
7276
|
});
|
|
6146
7277
|
payment.verifyPayment(rpc, request, protocolConfig).then((verified) => {
|
|
6147
7278
|
if (verified.verified) {
|
|
6148
|
-
|
|
7279
|
+
accept(verified, "ref path");
|
|
6149
7280
|
} else {
|
|
6150
7281
|
const reason = verified.error ?? "unknown";
|
|
6151
7282
|
refOutcome = { error: reason };
|
|
@@ -6196,23 +7327,28 @@ var AgentRuntime = class {
|
|
|
6196
7327
|
if (result.verified) {
|
|
6197
7328
|
return { netAmount, paymentRequest: requestJson };
|
|
6198
7329
|
}
|
|
6199
|
-
|
|
6200
|
-
|
|
6201
|
-
|
|
6202
|
-
|
|
7330
|
+
if (paymentRefusal?.consumedByOther === true) {
|
|
7331
|
+
log(
|
|
7332
|
+
`[${job.jobId.slice(0, 8)}] Payment not accepted: ${paymentRefusal.sentence}. The job stays recoverable - recovery keeps looking for a transfer of its own.`
|
|
7333
|
+
);
|
|
7334
|
+
} else if (paymentRefusal !== void 0) {
|
|
6203
7335
|
log(
|
|
6204
|
-
`[${job.jobId.slice(0, 8)}] Payment
|
|
7336
|
+
`[${job.jobId.slice(0, 8)}] Payment verified but NOT accepted: ${paymentRefusal.sentence}. The job stays recoverable and the next recovery tick retries it - see the REFUSED line above for what to fix.`
|
|
6205
7337
|
);
|
|
6206
7338
|
} else {
|
|
7339
|
+
let sigReport;
|
|
7340
|
+
if (sigOutcome === void 0) {
|
|
7341
|
+
sigReport = "signature path: no result";
|
|
7342
|
+
} else if (!sigOutcome.gotSignature) {
|
|
7343
|
+
sigReport = sigOutcome.error ? `signature path: no payment-completed feedback (${sigOutcome.error})` : "signature path: no payment-completed feedback";
|
|
7344
|
+
} else {
|
|
7345
|
+
sigReport = `signature path: the customer asserted a signature that did not verify (${sigOutcome.error ?? "unknown"})`;
|
|
7346
|
+
}
|
|
7347
|
+
const refReport = refOutcome === void 0 ? "no result" : refOutcome.error ?? "unknown";
|
|
6207
7348
|
log(
|
|
6208
|
-
`[${job.jobId.slice(0, 8)}] WARNING: Payment verification timed out.
|
|
7349
|
+
`[${job.jobId.slice(0, 8)}] WARNING: Payment verification timed out - reference path: ${refReport}; ${sigReport}. The customer may still have paid on-chain; recovery makes the final call. Check address ${this.config.solanaAddress} manually if it does not.`
|
|
6209
7350
|
);
|
|
6210
7351
|
}
|
|
6211
|
-
await this.transport.sendFeedback(job, { type: "error", message: "payment timeout" }).catch(() => {
|
|
6212
|
-
});
|
|
6213
|
-
if (customerAbandoned) {
|
|
6214
|
-
throw new Error("Payment timeout");
|
|
6215
|
-
}
|
|
6216
7352
|
throw new PaymentTimeoutError();
|
|
6217
7353
|
}
|
|
6218
7354
|
/**
|
|
@@ -6255,16 +7391,18 @@ var AgentRuntime = class {
|
|
|
6255
7391
|
}
|
|
6256
7392
|
async recoverPendingJobs() {
|
|
6257
7393
|
const pending = this.ledger.pendingJobs().filter((e) => !this.inFlight.has(e.job_id));
|
|
7394
|
+
this.recoveryDeferrals.sweep(new Set(this.ledger.pendingJobs().map((entry) => entry.job_id)));
|
|
6258
7395
|
if (this.sessionStore !== void 0) {
|
|
6259
7396
|
this.sessionStore.syncRecoveryRegistrations(
|
|
6260
7397
|
this.collectRecoverySessionRefs(pending),
|
|
6261
7398
|
(jobId) => this.ledger.getStatus(jobId) === "paid"
|
|
6262
7399
|
);
|
|
6263
7400
|
}
|
|
7401
|
+
const log = this.callbacks.onLog ?? console.log;
|
|
7402
|
+
this.logDeferralSummary(log);
|
|
6264
7403
|
if (pending.length === 0) {
|
|
6265
7404
|
return;
|
|
6266
7405
|
}
|
|
6267
|
-
const log = this.callbacks.onLog ?? console.log;
|
|
6268
7406
|
log(`Recovering ${pending.length} pending jobs...`);
|
|
6269
7407
|
if (this.healthMonitor) {
|
|
6270
7408
|
const snap = this.healthMonitor.snapshot();
|
|
@@ -6288,6 +7426,8 @@ var AgentRuntime = class {
|
|
|
6288
7426
|
}
|
|
6289
7427
|
}
|
|
6290
7428
|
}
|
|
7429
|
+
const scanBudget = recoveryScanBudgetPerTick(this.config.maxConcurrentJobs);
|
|
7430
|
+
let scansStartedThisTick = 0;
|
|
6291
7431
|
for (const entry of pending) {
|
|
6292
7432
|
const ageMs = (Math.floor(Date.now() / 1e3) - entry.created_at) * 1e3;
|
|
6293
7433
|
const expired = ageMs > MAX_PAID_AGE_MS;
|
|
@@ -6320,6 +7460,16 @@ var AgentRuntime = class {
|
|
|
6320
7460
|
if (!entry.raw_event_json) {
|
|
6321
7461
|
continue;
|
|
6322
7462
|
}
|
|
7463
|
+
if (this.recoveryDeferrals.isDeferred(entry.job_id)) {
|
|
7464
|
+
continue;
|
|
7465
|
+
}
|
|
7466
|
+
if (this.needsPaymentReVerification(entry)) {
|
|
7467
|
+
if (scansStartedThisTick >= scanBudget) {
|
|
7468
|
+
this.recoveryDeferrals.parkForCapacity(entry.job_id);
|
|
7469
|
+
continue;
|
|
7470
|
+
}
|
|
7471
|
+
scansStartedThisTick += 1;
|
|
7472
|
+
}
|
|
6323
7473
|
if (this.pending >= this.maxQueueSize) {
|
|
6324
7474
|
break;
|
|
6325
7475
|
}
|
|
@@ -6328,12 +7478,13 @@ var AgentRuntime = class {
|
|
|
6328
7478
|
this.limit(async () => {
|
|
6329
7479
|
try {
|
|
6330
7480
|
await this.recoverSingleJob(entry, log);
|
|
6331
|
-
} catch (
|
|
6332
|
-
log(`[${entry.job_id.slice(0, 8)}] Recovery: failed: ${
|
|
7481
|
+
} catch (err) {
|
|
7482
|
+
log(`[${entry.job_id.slice(0, 8)}] Recovery: failed: ${describeForOperator(err)}`);
|
|
6333
7483
|
} finally {
|
|
6334
7484
|
this.inFlight.delete(entry.job_id);
|
|
6335
7485
|
this.pending--;
|
|
6336
7486
|
}
|
|
7487
|
+
}).catch(() => {
|
|
6337
7488
|
});
|
|
6338
7489
|
}
|
|
6339
7490
|
}
|
|
@@ -6386,15 +7537,60 @@ var AgentRuntime = class {
|
|
|
6386
7537
|
const skill = this.skills.route(entry.tags);
|
|
6387
7538
|
if (!skill) {
|
|
6388
7539
|
log(`[${entry.job_id.slice(0, 8)}] Recovery: no skill for tags, marking failed`);
|
|
6389
|
-
this.
|
|
7540
|
+
await this.failRecoveredJob(entry, fakeJob, RECOVERY_NO_SKILL_CUSTOMER_MESSAGE);
|
|
6390
7541
|
return;
|
|
6391
7542
|
}
|
|
7543
|
+
if (skill.priceSubunits > 0 && !entry.net_amount) {
|
|
7544
|
+
if (entry.payment_request) {
|
|
7545
|
+
const reVerification = await this.paymentRecovery.reVerifyPayment(
|
|
7546
|
+
entry,
|
|
7547
|
+
entry.payment_request,
|
|
7548
|
+
skill.priceSubunits,
|
|
7549
|
+
log,
|
|
7550
|
+
recoveryAbort.signal
|
|
7551
|
+
);
|
|
7552
|
+
if (reVerification === "deferred") {
|
|
7553
|
+
this.recoveryDeferrals.note(entry.job_id);
|
|
7554
|
+
return;
|
|
7555
|
+
}
|
|
7556
|
+
if (reVerification === "awaiting-window") {
|
|
7557
|
+
this.recoveryDeferrals.noteAwaitingWindow(entry.job_id);
|
|
7558
|
+
return;
|
|
7559
|
+
}
|
|
7560
|
+
if (reVerification === "corrupt-state") {
|
|
7561
|
+
log(
|
|
7562
|
+
`[${entry.job_id.slice(0, 8)}] Recovery: the persisted payment request is unusable, so this job can never be verified - marking failed. This is a provider-side state problem; audit the ledger entry.`
|
|
7563
|
+
);
|
|
7564
|
+
await this.failRecoveredJob(entry, fakeJob, RECOVERY_UNVERIFIABLE_CUSTOMER_MESSAGE);
|
|
7565
|
+
return;
|
|
7566
|
+
}
|
|
7567
|
+
if (reVerification === "no-payment") {
|
|
7568
|
+
if (!this.recoveryDeferrals.sawNoPayment(entry.job_id)) {
|
|
7569
|
+
log(
|
|
7570
|
+
`[${entry.job_id.slice(0, 8)}] Recovery: the reference's whole history is empty, but a single listing is not enough to close a paid job - confirming on the next attempt.`
|
|
7571
|
+
);
|
|
7572
|
+
this.recoveryDeferrals.note(entry.job_id, true);
|
|
7573
|
+
return;
|
|
7574
|
+
}
|
|
7575
|
+
log(
|
|
7576
|
+
`[${entry.job_id.slice(0, 8)}] Recovery: two consecutive complete scans found no payment on this reference - marking failed.`
|
|
7577
|
+
);
|
|
7578
|
+
await this.failRecoveredJob(entry, fakeJob, RECOVERY_NO_PAYMENT_CUSTOMER_MESSAGE);
|
|
7579
|
+
return;
|
|
7580
|
+
}
|
|
7581
|
+
this.recoveryDeferrals.clear(entry.job_id);
|
|
7582
|
+
} else {
|
|
7583
|
+
log(`[${entry.job_id.slice(0, 8)}] Recovery: payment not confirmed, marking failed`);
|
|
7584
|
+
await this.failRecoveredJob(entry, fakeJob, RECOVERY_UNVERIFIABLE_CUSTOMER_MESSAGE);
|
|
7585
|
+
return;
|
|
7586
|
+
}
|
|
7587
|
+
}
|
|
6392
7588
|
const healthPair = resolveHealthPair(skill);
|
|
6393
7589
|
if (this.healthMonitor && healthPair) {
|
|
6394
7590
|
try {
|
|
6395
7591
|
await this.healthMonitor.assertReady(healthPair.provider, healthPair.model);
|
|
6396
7592
|
} catch (err) {
|
|
6397
|
-
if (err
|
|
7593
|
+
if (isLlmHealthError(err)) {
|
|
6398
7594
|
log(
|
|
6399
7595
|
`[${entry.job_id.slice(0, 8)}] Recovery: pair ${healthPair.provider}/${healthPair.model} still unhealthy (${err.reason}); waiting for recovery probe.`
|
|
6400
7596
|
);
|
|
@@ -6423,24 +7619,6 @@ var AgentRuntime = class {
|
|
|
6423
7619
|
}
|
|
6424
7620
|
}
|
|
6425
7621
|
this.ledger.incrementRetry(entry.job_id);
|
|
6426
|
-
if (skill.priceSubunits > 0 && !entry.net_amount) {
|
|
6427
|
-
if (entry.payment_request) {
|
|
6428
|
-
const verified = await this.reVerifyPayment(
|
|
6429
|
-
entry,
|
|
6430
|
-
skill.priceSubunits,
|
|
6431
|
-
log,
|
|
6432
|
-
recoveryAbort.signal
|
|
6433
|
-
);
|
|
6434
|
-
if (!verified) {
|
|
6435
|
-
this.ledger.markFailed(entry.job_id);
|
|
6436
|
-
return;
|
|
6437
|
-
}
|
|
6438
|
-
} else {
|
|
6439
|
-
log(`[${entry.job_id.slice(0, 8)}] Recovery: payment not confirmed, marking failed`);
|
|
6440
|
-
this.ledger.markFailed(entry.job_id);
|
|
6441
|
-
return;
|
|
6442
|
-
}
|
|
6443
|
-
}
|
|
6444
7622
|
let recoveryInputFile;
|
|
6445
7623
|
let recoverySession;
|
|
6446
7624
|
try {
|
|
@@ -6456,9 +7634,17 @@ var AgentRuntime = class {
|
|
|
6456
7634
|
entry.customer_id,
|
|
6457
7635
|
recoveryAbort.signal
|
|
6458
7636
|
);
|
|
6459
|
-
} catch {
|
|
7637
|
+
} catch (raw) {
|
|
7638
|
+
const err = asHostScratchFailure(raw) ?? raw;
|
|
7639
|
+
if (isHostScratchError(err)) {
|
|
7640
|
+
this.scratchFailed = true;
|
|
7641
|
+
log(
|
|
7642
|
+
`[${entry.job_id.slice(0, 8)}] Recovery: no scratch space for the input file; leaving the job paid.`
|
|
7643
|
+
);
|
|
7644
|
+
return;
|
|
7645
|
+
}
|
|
6460
7646
|
log(`[${entry.job_id.slice(0, 8)}] Recovery: input file unavailable, marking failed`);
|
|
6461
|
-
this.
|
|
7647
|
+
await this.failRecoveredJob(entry, fakeJob, RECOVERY_INPUT_UNAVAILABLE_CUSTOMER_MESSAGE);
|
|
6462
7648
|
return;
|
|
6463
7649
|
}
|
|
6464
7650
|
const recoveryBudgetMs = this.resolveExecutionBudgetMs(skill);
|
|
@@ -6467,6 +7653,7 @@ var AgentRuntime = class {
|
|
|
6467
7653
|
skill
|
|
6468
7654
|
);
|
|
6469
7655
|
const runRecoveryExecution = async (history) => {
|
|
7656
|
+
let budgetExceeded = false;
|
|
6470
7657
|
let budgetTimer;
|
|
6471
7658
|
try {
|
|
6472
7659
|
const execPromise = skill.execute(
|
|
@@ -6488,6 +7675,7 @@ var AgentRuntime = class {
|
|
|
6488
7675
|
execPromise,
|
|
6489
7676
|
new Promise((_resolve, reject) => {
|
|
6490
7677
|
budgetTimer = setTimeout(() => {
|
|
7678
|
+
budgetExceeded = true;
|
|
6491
7679
|
recoveryAbort.abort();
|
|
6492
7680
|
reject(new ExecutionBudgetExceededError(recoveryBudgetMs));
|
|
6493
7681
|
}, recoveryBudgetMs);
|
|
@@ -6496,6 +7684,12 @@ var AgentRuntime = class {
|
|
|
6496
7684
|
}
|
|
6497
7685
|
return await execPromise;
|
|
6498
7686
|
} catch (err) {
|
|
7687
|
+
if (isHostScratchError(err)) {
|
|
7688
|
+
this.scratchFailed = true;
|
|
7689
|
+
}
|
|
7690
|
+
if (budgetExceeded) {
|
|
7691
|
+
throw new ExecutionBudgetExceededError(recoveryBudgetMs);
|
|
7692
|
+
}
|
|
6499
7693
|
this.markHealthFromExecuteError(skill, err, log, entry.job_id);
|
|
6500
7694
|
throw err;
|
|
6501
7695
|
} finally {
|
|
@@ -6508,18 +7702,28 @@ var AgentRuntime = class {
|
|
|
6508
7702
|
}
|
|
6509
7703
|
}
|
|
6510
7704
|
};
|
|
6511
|
-
|
|
6512
|
-
|
|
6513
|
-
|
|
6514
|
-
|
|
6515
|
-
|
|
6516
|
-
|
|
6517
|
-
|
|
6518
|
-
|
|
6519
|
-
|
|
6520
|
-
|
|
6521
|
-
|
|
6522
|
-
|
|
7705
|
+
let output;
|
|
7706
|
+
try {
|
|
7707
|
+
output = recoveryJobSession === null ? await runRecoveryExecution() : await this.runSessionExchange({
|
|
7708
|
+
session: recoveryJobSession,
|
|
7709
|
+
jobId: entry.job_id,
|
|
7710
|
+
userRecord: buildUserRecord(
|
|
7711
|
+
recoveryInputFile?.inlineText ?? entry.input,
|
|
7712
|
+
recoveryInputFile?.filePath !== void 0 ? "attachment" : void 0
|
|
7713
|
+
),
|
|
7714
|
+
signal: recoveryAbort.signal,
|
|
7715
|
+
log,
|
|
7716
|
+
execute: runRecoveryExecution,
|
|
7717
|
+
excludeOwnTurns: true
|
|
7718
|
+
});
|
|
7719
|
+
} catch (error) {
|
|
7720
|
+
if (!isScriptRefusalError(error)) {
|
|
7721
|
+
throw error;
|
|
7722
|
+
}
|
|
7723
|
+
log(`[${entry.job_id.slice(0, 8)}] Recovery: ${describeForOperator(error)}`);
|
|
7724
|
+
await this.failRecoveredJob(entry, fakeJob, customerSafeMessage(error));
|
|
7725
|
+
return;
|
|
7726
|
+
}
|
|
6523
7727
|
const { attachments: resultAttachments, deliveredContent } = await this.buildResultAttachment(
|
|
6524
7728
|
entry.job_id,
|
|
6525
7729
|
output,
|
|
@@ -6540,61 +7744,6 @@ var AgentRuntime = class {
|
|
|
6540
7744
|
this.jobAbortControllers.delete(recoveryAbort);
|
|
6541
7745
|
}
|
|
6542
7746
|
}
|
|
6543
|
-
/**
|
|
6544
|
-
* Re-verify an on-chain payment during crash recovery.
|
|
6545
|
-
*
|
|
6546
|
-
* Limitation: Solana transaction data expires after ~2-3 days (recent blockhash window).
|
|
6547
|
-
* If the agent was down longer, a confirmed payment may not be found on-chain and the
|
|
6548
|
-
* job will be marked failed. For mainnet: use monitoring, avoid extended downtime, or
|
|
6549
|
-
* configure an archive RPC via SOLANA_RPC_URL.
|
|
6550
|
-
*/
|
|
6551
|
-
async reVerifyPayment(entry, priceSubunits, log, signal) {
|
|
6552
|
-
try {
|
|
6553
|
-
const request = JSON.parse(entry.payment_request);
|
|
6554
|
-
const rpc = createSolanaRpc(getRpcUrl(this.config.network));
|
|
6555
|
-
const protocolConfig = await this.fetchProtocolConfig();
|
|
6556
|
-
let result;
|
|
6557
|
-
if (signal) {
|
|
6558
|
-
let abortHandler;
|
|
6559
|
-
const abortPromise = new Promise((_, reject) => {
|
|
6560
|
-
abortHandler = () => {
|
|
6561
|
-
const err = new Error("The operation was aborted");
|
|
6562
|
-
err.name = "AbortError";
|
|
6563
|
-
reject(err);
|
|
6564
|
-
};
|
|
6565
|
-
if (signal.aborted) {
|
|
6566
|
-
abortHandler();
|
|
6567
|
-
return;
|
|
6568
|
-
}
|
|
6569
|
-
signal.addEventListener("abort", abortHandler, { once: true });
|
|
6570
|
-
});
|
|
6571
|
-
try {
|
|
6572
|
-
result = await Promise.race([
|
|
6573
|
-
payment.verifyPayment(rpc, request, protocolConfig),
|
|
6574
|
-
abortPromise
|
|
6575
|
-
]);
|
|
6576
|
-
} finally {
|
|
6577
|
-
if (abortHandler) {
|
|
6578
|
-
signal.removeEventListener("abort", abortHandler);
|
|
6579
|
-
}
|
|
6580
|
-
}
|
|
6581
|
-
} else {
|
|
6582
|
-
result = await payment.verifyPayment(rpc, request, protocolConfig);
|
|
6583
|
-
}
|
|
6584
|
-
if (result.verified) {
|
|
6585
|
-
const fee = calculateProtocolFee(priceSubunits, protocolConfig.feeBps);
|
|
6586
|
-
const netAmount = priceSubunits - fee;
|
|
6587
|
-
this.ledger.updatePayment(entry.job_id, netAmount, entry.payment_request);
|
|
6588
|
-
log(`[${entry.job_id.slice(0, 8)}] Recovery: payment re-verified (${netAmount} subunits)`);
|
|
6589
|
-
return true;
|
|
6590
|
-
}
|
|
6591
|
-
log(`[${entry.job_id.slice(0, 8)}] Recovery: payment not found on-chain, marking failed`);
|
|
6592
|
-
return false;
|
|
6593
|
-
} catch (e) {
|
|
6594
|
-
log(`[${entry.job_id.slice(0, 8)}] Recovery: payment re-verification error: ${e.message}`);
|
|
6595
|
-
return false;
|
|
6596
|
-
}
|
|
6597
|
-
}
|
|
6598
7747
|
};
|
|
6599
7748
|
var SkillRegistry = class {
|
|
6600
7749
|
skills = [];
|
|
@@ -7800,21 +8949,17 @@ function withPaymentIdentifier(headerValue, declaration, jobId) {
|
|
|
7800
8949
|
return headerValue;
|
|
7801
8950
|
}
|
|
7802
8951
|
}
|
|
7803
|
-
var UNICODE_FORMAT_MARKS = /\p{Cf}/gu;
|
|
7804
|
-
function flattenForOperator(text) {
|
|
7805
|
-
return sanitizeForTerminal(text).replace(UNICODE_FORMAT_MARKS, "").replace(/\s+/g, " ").trim();
|
|
7806
|
-
}
|
|
7807
8952
|
function printedPrefix(flattened) {
|
|
7808
|
-
return flattened
|
|
8953
|
+
return clipToCodeUnits(flattened, X402_ERROR_EXCERPT_CHARS);
|
|
7809
8954
|
}
|
|
7810
8955
|
function clipForOperator(flattened) {
|
|
7811
8956
|
return flattened.length <= X402_ERROR_EXCERPT_CHARS ? flattened : `${printedPrefix(flattened)}...`;
|
|
7812
8957
|
}
|
|
7813
8958
|
function quoteUpstream(text) {
|
|
7814
|
-
return clipForOperator(
|
|
8959
|
+
return clipForOperator(flattenForComparison(text));
|
|
7815
8960
|
}
|
|
7816
8961
|
function quoteUpstreamWithInput(text, sentInput) {
|
|
7817
|
-
return clipForOperator(maskCustomerInput(
|
|
8962
|
+
return clipForOperator(maskCustomerInput(flattenForComparison(text), inputForms(sentInput)));
|
|
7818
8963
|
}
|
|
7819
8964
|
function wireForm(sentInput) {
|
|
7820
8965
|
return new TextDecoder().decode(new TextEncoder().encode(sentInput));
|
|
@@ -7836,7 +8981,7 @@ function endsInsideInput(text, forms) {
|
|
|
7836
8981
|
}
|
|
7837
8982
|
function inputForms(sentInput) {
|
|
7838
8983
|
return [wireForm(sentInput), jsonEscapedForm(sentInput), ...urlEncodedForms(sentInput)].map(
|
|
7839
|
-
|
|
8984
|
+
flattenForComparison
|
|
7840
8985
|
);
|
|
7841
8986
|
}
|
|
7842
8987
|
function maskCustomerInput(text, forms) {
|
|
@@ -7880,7 +9025,7 @@ var X402Driver = class {
|
|
|
7880
9025
|
signerPromise = null;
|
|
7881
9026
|
repriceHintLogged = /* @__PURE__ */ new Set();
|
|
7882
9027
|
log(message) {
|
|
7883
|
-
(this.options.log ?? console.log)(`[x402] ${
|
|
9028
|
+
(this.options.log ?? console.log)(`[x402] ${flattenForComparison(message)}`);
|
|
7884
9029
|
}
|
|
7885
9030
|
getSigner() {
|
|
7886
9031
|
const secret = this.options.solanaSecretKeyBase58;
|
|
@@ -8084,7 +9229,7 @@ var X402Driver = class {
|
|
|
8084
9229
|
this.options.errorBodyReadMs ?? X402_ERROR_BODY_READ_MS
|
|
8085
9230
|
);
|
|
8086
9231
|
const forms = inputForms(sentInput);
|
|
8087
|
-
const quoted = maskCustomerInput(
|
|
9232
|
+
const quoted = maskCustomerInput(flattenForComparison(body.text), forms);
|
|
8088
9233
|
if (quoted.length === 0) {
|
|
8089
9234
|
return "";
|
|
8090
9235
|
}
|
|
@@ -8736,7 +9881,7 @@ async function cmdStart(nameArg, options = {}) {
|
|
|
8736
9881
|
}
|
|
8737
9882
|
try {
|
|
8738
9883
|
await client.policies.deletePolicy(identity2, type);
|
|
8739
|
-
console.log(` Removed stale policy: ${
|
|
9884
|
+
console.log(` Removed stale policy: ${deleteControlCharacters(type)}`);
|
|
8740
9885
|
} catch {
|
|
8741
9886
|
}
|
|
8742
9887
|
}
|
|
@@ -8847,7 +9992,7 @@ async function cmdStart(nameArg, options = {}) {
|
|
|
8847
9992
|
const card = JSON.parse(ev.content);
|
|
8848
9993
|
if (card.name && toDTag(card.name) === dTag) {
|
|
8849
9994
|
await client.discovery.deleteCapability(identity2, card.name);
|
|
8850
|
-
console.log(` Removed stale capability: ${
|
|
9995
|
+
console.log(` Removed stale capability: ${deleteControlCharacters(card.name)}`);
|
|
8851
9996
|
}
|
|
8852
9997
|
} catch {
|
|
8853
9998
|
}
|
|
@@ -8940,7 +10085,7 @@ async function cmdStart(nameArg, options = {}) {
|
|
|
8940
10085
|
ledger,
|
|
8941
10086
|
{
|
|
8942
10087
|
onJobReceived: (job) => {
|
|
8943
|
-
const cap =
|
|
10088
|
+
const cap = deleteControlCharacters(job.tags.find((t) => t !== "elisym") ?? "unknown");
|
|
8944
10089
|
process.stdout.write(` [job] ${job.jobId.slice(0, 16)} | cap=${cap}
|
|
8945
10090
|
`);
|
|
8946
10091
|
logger.info({ event: "job_received", jobId: job.jobId, capability: cap });
|
|
@@ -8951,7 +10096,7 @@ async function cmdStart(nameArg, options = {}) {
|
|
|
8951
10096
|
logger.info({ event: "job_delivered", jobId });
|
|
8952
10097
|
},
|
|
8953
10098
|
onJobError: (jobId, error) => {
|
|
8954
|
-
const safeError =
|
|
10099
|
+
const safeError = deleteControlCharacters(error);
|
|
8955
10100
|
process.stderr.write(` [job] ${jobId.slice(0, 16)} | error: ${safeError}
|
|
8956
10101
|
`);
|
|
8957
10102
|
logger.error({ event: "job_error", jobId, error: safeError });
|