@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 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, DEFAULTS, createBlossomTransport, generateSolanaWallet, verifyAgentIdentities, getProtocolConfig, getProtocolProgramId, calculateProtocolFee, LIMITS, makeCensor, DEFAULT_REDACT_PATHS, POLICY_T_TAG, SESSION_ID_REGEX, createSlidingWindowLimiter, utf8ByteLength, readAcceptedTransports, resolveDelegationAsset, MAX_PROOF_TTL_SECS, PROOF_CLOCK_SKEW_SECS, verifyDelegationAuthProof, deriveOwnerDelegationAta, getDelegation, buildDelegatedTransfer, buildSignedPull, sendConfirmToTerminal, parseDelegatedPayment, confirmPullToTerminal, isDefinitelyUnpaid, decodeJobPayload, BoundedSet, KIND_JOB_FEEDBACK, encodeSecretKeyBase58, GITHUB_USERNAME_REGEX, X_USERNAME_REGEX, normalizeNip05Identifier, splitNip05Identifier, NATIVE_SOL, GIST_ID_REGEX, TWEET_ID_REGEX } from '@elisym/sdk';
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, readFile, mkdtemp, rm, rename, stat } from 'node:fs/promises';
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, ScriptBillingExhaustedError, ScriptExecutionError, FREE_LLM_GLOBAL_KEY, freeLlmCustomerKey, LlmHealthError } from '@elisym/sdk/llm-health';
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
- this.entries.set(id, entry);
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(` ${sanitizeForTerminal(line)}
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
- "credit balance",
4508
- "billing",
4509
- "insufficient",
4510
- "insufficient_quota",
4511
- "x-api-key",
4512
- "invalid api key",
4513
- "invalid_api_key",
4514
- "authentication_error",
4515
- "unauthorized",
4516
- "unauthenticated"
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
- function scriptMessageLooksLikeBillingOrInvalid(message) {
4519
- const lower = message.toLowerCase();
4520
- return SCRIPT_BILLING_INVALID_MARKERS.some((marker) => lower.includes(marker));
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 verification timed out; awaiting late confirmation.");
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 instanceof ScriptExecutionError) {
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 ScriptBillingExhaustedError) {
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 "Internal processing error";
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 instanceof ScriptBillingExhaustedError) {
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(provider, model, "billing", err.message);
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] ?? "").slice(0, 200);
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
- if (err instanceof ScriptExecutionError) {
4877
- message = err.detail;
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 ("${message.slice(0, 120)}") but did not declare provider/model in SKILL.md - cannot gate future jobs.`
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 lower = message.toLowerCase();
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 stderr` : `generic exit (no billing/invalid markers, classified as ${reason}, skill-local)`;
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(provider, model, reason, message.slice(0, 200), {
4901
- cascade
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((e) => {
5018
- this.callbacks.onJobError?.(job.jobId, e.message);
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 (e) {
6133
+ } catch (raw) {
6134
+ const e = asHostScratchFailure(raw) ?? raw;
5104
6135
  const log = this.callbacks.onLog ?? console.log;
5105
- log(`[${job.jobId.slice(0, 8)}] Error: ${e.message}`);
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 || e instanceof SeedFailedError || e instanceof PaymentTimeoutError || e instanceof X402TransientError || e instanceof ExecutionBudgetExceededError && this.skills.route(job.tags)?.mode === "x402") && currentStatus === "paid";
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
- if (entry.raw_event_json === void 0) {
5645
- return false;
5646
- }
5647
- try {
5648
- const raw = JSON.parse(entry.raw_event_json);
5649
- return Array.isArray(raw.tags) && raw.tags.some((tag) => Array.isArray(tag) && tag[0] === "payment" && tag[1] === "delegated");
5650
- } catch {
5651
- return false;
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
- win(verified);
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
- win(verified);
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
- const refDefinitiveNoPay = refOutcome?.error === "No matching transaction found for reference key";
6200
- const sigGotSignature = sigOutcome?.gotSignature === true;
6201
- const customerAbandoned = !sigGotSignature && refDefinitiveNoPay;
6202
- if (customerAbandoned) {
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 not received; on-chain scan found no matching transaction - job abandoned by customer.`
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. Customer may have paid on-chain. Check address ${this.config.solanaAddress} manually.`
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 (e) {
6332
- log(`[${entry.job_id.slice(0, 8)}] Recovery: failed: ${e.message}`);
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.ledger.markFailed(entry.job_id);
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 instanceof LlmHealthError) {
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.ledger.markFailed(entry.job_id);
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
- const output = recoveryJobSession === null ? await runRecoveryExecution() : await this.runSessionExchange({
6512
- session: recoveryJobSession,
6513
- jobId: entry.job_id,
6514
- userRecord: buildUserRecord(
6515
- recoveryInputFile?.inlineText ?? entry.input,
6516
- recoveryInputFile?.filePath !== void 0 ? "attachment" : void 0
6517
- ),
6518
- signal: recoveryAbort.signal,
6519
- log,
6520
- execute: runRecoveryExecution,
6521
- excludeOwnTurns: true
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.length <= X402_ERROR_EXCERPT_CHARS ? flattened : flattened.slice(0, X402_ERROR_EXCERPT_CHARS).replace(/[\ud800-\udbff]$/, "");
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(flattenForOperator(text));
8959
+ return clipForOperator(flattenForComparison(text));
7815
8960
  }
7816
8961
  function quoteUpstreamWithInput(text, sentInput) {
7817
- return clipForOperator(maskCustomerInput(flattenForOperator(text), inputForms(sentInput)));
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
- flattenForOperator
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] ${flattenForOperator(message)}`);
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(flattenForOperator(body.text), forms);
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: ${sanitizeForTerminal(type)}`);
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: ${sanitizeForTerminal(card.name)}`);
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 = sanitizeForTerminal(job.tags.find((t) => t !== "elisym") ?? "unknown");
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 = sanitizeForTerminal(error);
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 });