Skip to content

Split the offer deadline into a claim deadline and an execution budget #945

Description

@maxy-player

deadline_unix on an offer is one absolute clock. It decides whether a seller may
claim, and it also bounds how long the agent may run. A seller that claims late gets
only the remainder.

All citations are at aa172e660f5395c72db2124b0a37e7f6ba0ae05a.

offer posted ───── claim ───── award ───── deadline_unix
                                    execution gets what is left ──┘

The claim gate

// Offer-freshness (money-safety): an offer whose own absolute deadline already passed is dead,
// refused here before `job_deadline_unix` could hand it a fresh window.
if offer.deadline_unix <= now_unix {
return ClaimDecision::Skip(SkipReason::Lapsed);
}

// Offer-freshness (money-safety): an offer whose own absolute deadline already passed is dead,
// refused here before `job_deadline_unix` could hand it a fresh window.
if offer.deadline_unix <= now_unix {
    return ClaimDecision::Skip(SkipReason::Lapsed);
}

The test is <=, so an offer with one second remaining is still claimable. Nothing
sets a floor on the window a claim may accept.

The same clock reaches execution

The stored row keeps the wire deadline:

fn offer_row(job_id: &str, buyer_pubkey: &str, offer: &ParsedOffer) -> super::store::Offer {
super::store::Offer {
offer_id: job_id.to_owned(),
buyer_pubkey: buyer_pubkey.to_owned(),
amount_sats: offer.amount,
unit: offer.unit.clone(),
task: offer.task.clone(),
deadline_unix: offer.deadline_unix as i64,
targeted: offer.is_targeted(),

fn offer_row(job_id: &str, buyer_pubkey: &str, offer: &ParsedOffer) -> super::store::Offer {
    super::store::Offer {
        ...
        deadline_unix: offer.deadline_unix as i64,

execute_job reads that row:

let offer = match self.node.store().offer_row(job_id) {
Ok(offer) => offer,
Err(error) => {
opline!("seller node execute job_id={job_id}: offer read failed ({error}); treating deadline as live");
None
}
};
let deadline_unix = offer.as_ref().map(|offer| offer.deadline_unix);

let deadline = offer.deadline_unix.max(0) as u64;

let deadline = offer.deadline_unix.max(0) as u64;

…and the agent runs under it:

let run_result = run_agent_with_retry(
deadline,
MAX_AGENT_ATTEMPTS,
|| now_unix() as u64,
|_attempt| {
let job_timeout = unified_job_timeout(deadline, now_unix() as u64);

let run_result = run_agent_with_retry(
    deadline,
    MAX_AGENT_ATTEMPTS,
    || now_unix() as u64,
    |_attempt| {
        let job_timeout = unified_job_timeout(deadline, now_unix() as u64);

The timeout is the remainder, saturating to zero:

/// The ONE coherent job timeout. The ACP driver's idle/response timeout is derived from the job's
/// own deadline (`--job-timeout-secs` → offer deadline → default, via [`crate::seller::job_deadline_unix`])
/// so a job has a single predictable deadline. Saturating: a non-positive remaining window yields
/// `Duration::ZERO`, which fails the run cleanly at the deadline rather than hanging.
pub fn unified_job_timeout(deadline_unix: u64, now_unix: u64) -> Duration {
Duration::from_secs(deadline_unix.saturating_sub(now_unix))
}

pub fn unified_job_timeout(deadline_unix: u64, now_unix: u64) -> Duration {
    Duration::from_secs(deadline_unix.saturating_sub(now_unix))
}

What that means for a buyer's contract

A 10-minute offer claimed 8 minutes in gives the runner about 2 minutes, not a fresh
10. Three details make the real budget smaller than "time since claim" suggests:

  • Execution begins at award, not at claim, so the budget is
    deadline − award_time.
  • MAX_AGENT_ATTEMPTS = 3 (run.rs:69) retries all live inside that same shrinking
    window.
  • A job awarded at or past the deadline receives Duration::ZERO and fails at once.

The clocks after delivery are unaffected and are not part of this: the buyer's
payment window and the seller's result-publication retry window protect settlement
and retries. Neither extends agent execution.

The existing override does not supply the second clock

[seller].job_timeout_secs and --job-timeout-secs read as an execution-budget
override and are documented as one, but the value they produce never reaches
execution. That is filed separately as #944 — it is closeable on its own and does
not depend on this issue.

It is relevant here in one direction only: if the maintainers choose to make
job_timeout_secs live rather than remove it, the interaction between an operator's
budget and the buyer's deadline becomes the design question this issue is about.

Proposal, offered for discussion

Split the protocol into:

  • claim_deadline_unix — how long the offer stays claimable, and
  • execution_budget_secs — a budget that starts at award,

while keeping settlement as its own clock. A late claim then becomes safe and the
buyer's contract states one thing rather than two. Separately, a seller-side
soft-finalization buffer would address an agent that is nearly finished when the
window closes.

We have not implemented any of this and are not proposing an implementation here.
Open questions we can see, and do not answer:

  • Who bounds execution_budget_secs — the buyer on the offer, or the seller's
    config, or a cap on both?
  • Does the buyer's total exposure become claim_deadline + execution_budget, and is
    that acceptable?
  • What happens to an already-deployed seller that reads offers with only the old
    field?

Versions

Read with git show <rev>:<path> at four revisions:

State Rev
MakePrisms main, as read 54cf74b8334c64c5a1975bac698f68170c0e7301
citation rev for the links above aa172e660f5395c72db2124b0a37e7f6ba0ae05a
v0.5.4 b3dc79936923fbdc79870c7cb31266a02748add8
v0.5.5 ebaa796e6fea1b00c67769bf412ad4dc5f93d0e7

seller_exec.rs is byte-identical at all four (cmp). run.rs differs, but each
site is present at every rev with the same code; only the line numbers move:

Site v0.5.4 v0.5.5 aa172e66 / 54cf74b8
claim gate 2134 2176 2176
offer_row writes the wire deadline 1785 1785 1785
execution reads the row 5961 6003 6003
unified_job_timeout(deadline, …) 5979 6028 6028

aa172e66 is in no release tag; it is an ancestor of main, so the link line numbers
are main line numbers. The clone this was read from last fetched MakePrisms at
2026-08-31T22:18:23Z; anything merged after that is outside what we read.

What we did not check

  • No build and no test run. Static reading at four revisions. We did not compile
    a seller and observe a late claim receive a short window.
  • No deployed binary was inspected. Source at tags, not any operator's running
    node.
  • Files read: seller_node/run.rs, seller_exec.rs, seller.rs, home.rs,
    sell.rs. The buyer side and the settlement clocks were not audited.

Labels

We cannot set labels on this repository. Requested: protocol, seller,
discussion — maintainer's call.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions