Skip to content

fix(trader)(#30): short-TTL tarpit on proposal-timeout counterparty - #31

Open
vrogojin wants to merge 1 commit into
masterfrom
fix/issue-30-counterparty-tarpit
Open

fix(trader)(#30): short-TTL tarpit on proposal-timeout counterparty#31
vrogojin wants to merge 1 commit into
masterfrom
fix/issue-30-counterparty-tarpit

Conversation

@vrogojin

Copy link
Copy Markdown
Owner

Summary

Fixes #30. When a counterparty went silent during NP-0 negotiation, the matcher would deterministically re-pick the same dead candidate on every scan tick — burning the entire match cycle on a corpse while live candidates were starved. Now, after K=2 consecutive proposal failures, the counterparty is excluded from that intent's candidate pool for ~5 minutes.

What changed

  • types.ts — new CancellationReason type; OnDealCancelled now receives a reason argument (transient — not persisted).
  • negotiation-handler.tstransitionDeal accepts an optional reason and forwards it to onDealCancelled. Each existing transitionDeal(_, 'CANCELLED', ...) callsite passes its semantic reason (proposal_timeout, proposal_send_failed, accept_send_failed, counterparty_rejected, sibling_cancelled, shutdown).
  • intent-engine.tsfailedCounterparties value type promoted from Set<string> to Map<string, TarpitEntry> carrying failureCount, tarpitUntil, lastFailureAt. New recordCounterpartyFailure() (soft-threshold) and clearCounterpartyFailures() (success-reset). Existing markCounterpartyFailed() is now the strong-signal immediate-tarpit primitive (used by the W2 yield fall-through).
  • trader-main.tsonDealCancelled discriminates by reason: on proposal_timeout/proposal_send_failed, call recordCounterpartyFailure. On onDealAccepted (proposer side only), call clearCounterpartyFailures so consecutive really means consecutive.

Policy

Knob Value Why
Threshold (K) 2 Tolerate one lost Nostr DM without penalising peer
TTL 5 min Skip ~10 scan cycles at default 30s interval; recovered peers rejoin quickly
Counter decay 10 min A peer that flapped 2 hours ago doesn't get insta-tarpitted on one modern miss
Capacity per intent 1000 (LRU) Inherited from existing bound

Test plan

  • npm run lint — clean
  • npm run typecheck — clean
  • npm run test — 711/711 pass (10 new tests)
  • npm run build — clean
  • Adversarial steelman pass surfaced the success-reset hole; covered with clearCounterpartyFailures + dedicated test

Tests added

src/trader/intent-engine.test.ts:

  • threshold gating (1 failure ≠ tarpit; 2 = tarpit)
  • live candidate gets selected when dead one is tarpitted (issue's soak scenario)
  • TTL expiry re-admits counterparty
  • per-intent isolation (tarpit on A doesn't affect B)
  • counter decays after 10 min of quiet
  • accepts market_intent_id or local intent_id
  • markCounterpartyFailed tarpits on first call
  • clearCounterpartyFailures resets counter
  • clearCounterpartyFailures on absent entry is no-op

src/trader/negotiation-handler.test.ts:

  • forwards reason="proposal_timeout" on PROPOSED 30s timer
  • forwards reason="counterparty_rejected" on incoming np.reject_deal
  • forwards reason="shutdown" on cancelPending()

Previously, when a counterparty went silent during NP-0 negotiation
(np.propose_deal sent, np.accept_deal never arrived), the matcher would
re-pick the same dead candidate on the next scan tick, burn another 30s
on the proposal timeout, and starve the live candidates in the feed.
Issue #30 documents the soak failure mode where a single dead acceptor
monopolised alice's entire 15-minute match cycle.

Fix: thread a CancellationReason through transitionDeal -> onDealCancelled
so trader-main can react reason-specifically. On proposal_timeout and
proposal_send_failed, call IntentEngine.recordCounterpartyFailure() to
increment a per-intent failure counter; after K=2 consecutive failures
the counterparty is tarpitted for 5 minutes (long enough to skip ~10
scan cycles, short enough that a recovered peer rejoins quickly).
AGENT_BUSY rejection and sibling-cancellation keep the historical
no-blacklist semantics — they are race-only and fully recoverable.

The existing markCounterpartyFailed() (used by the W2 yield-timeout
fall-through) becomes the strong-signal immediate-tarpit primitive,
sharing the same failedCounterparties Map. A new
clearCounterpartyFailures() resets the counter when the counterparty
proves responsive (called from onDealAccepted on the proposer side) so
"K consecutive" really means consecutive, not "K within the decay
window regardless of intermediate successes."

Tests cover: threshold gating, immediate tarpit, TTL expiry, per-intent
isolation, counter decay, ID resolution (local vs market intent_id),
success-reset contract, and forwarding of each cancellation reason
through onDealCancelled.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Negotiation matcher reattempts unresponsive counterparty every scan tick — needs short-TTL tarpit on proposal timeout

1 participant