Skip to content

Reconcile [seller].job_timeout_secs with the deadline execution uses #944

Description

@maxy-player

unified_job_timeout documents a live precedence chain with --job-timeout-secs at
its head. That chain no longer decides anything. The value job_deadline_unix
computes reaches one operator log line and is then discarded; execution enforces the
offer's wire deadline instead.

All citations are at aa172e660f5395c72db2124b0a37e7f6ba0ae05a.

The documentation says the chain is live

/// 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))
}

/// 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))
}

The config field documents the same override:

pub git_remote: String,
/// Job deadline override (seconds). Default: offer `deadline_unix`, else ~600s.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub job_timeout_secs: Option<u64>,

/// Job deadline override (seconds). Default: offer `deadline_unix`, else ~600s.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub job_timeout_secs: Option<u64>,

The function honours it

/// Job deadline: config override, else offer deadline, else now+DEFAULT.
pub fn job_deadline_unix(offer: &ParsedOffer, seller: &SellerConfig, now_unix: u64) -> u64 {
if let Some(secs) = seller.job_timeout_secs {
return now_unix.saturating_add(secs);
}
if offer.deadline_unix > now_unix {
return offer.deadline_unix;
}
now_unix.saturating_add(DEFAULT_JOB_TIMEOUT_SECS)
}

/// Job deadline: config override, else offer deadline, else now+DEFAULT.
pub fn job_deadline_unix(offer: &ParsedOffer, seller: &SellerConfig, now_unix: u64) -> u64 {
    if let Some(secs) = seller.job_timeout_secs {
        return now_unix.saturating_add(secs);
    }
    if offer.deadline_unix > now_unix {
        return offer.deadline_unix;
    }
    now_unix.saturating_add(DEFAULT_JOB_TIMEOUT_SECS)
}

It returns a fresh window measured from the moment of the call. That is exactly what
the config doc promises.

The result reaches one log line

job_deadline_unix has one call site in the repository:

ClaimDecision::Claim {
deadline_unix: crate::seller::job_deadline_unix(offer, seller, now_unix),
}

ClaimDecision::Claim {
    deadline_unix: crate::seller::job_deadline_unix(offer, seller, now_unix),
}

ClaimDecision::Claim has two non-test consumers. Both pass the value straight into
claim_offer:

ClaimDecision::Claim { deadline_unix } => {
self.claim_offer(
&row.offer_id,
&row.buyer_pubkey,
&offer,
&seller_pubkey,
deadline_unix,
now,
// A recorded offer re-driven from the store carries no tags; its pin (if any)
// was already written at the original claim (INSERT OR IGNORE — idempotent).
None,
)

let deadline_unix = match classify_offer(&offer, &seller, &self.agents, &seller_pubkey, &buyer_pubkey, now as u64, event.created_at.as_secs())
{
ClaimDecision::Claim { deadline_unix } => deadline_unix,

let job_id = event.id.to_hex();
self.claim_offer(
&job_id,
&buyer_pubkey,
&offer,
&seller_pubkey,
deadline_unix,
now,
contribution.as_ref(),
)

Inside claim_offer (run.rs:48805062) the parameter is read exactly once, in a
format string:

Ok(super::store::Claimed::New) => {
opline!(
"seller node claimed job_id={job_id} buyer={buyer_pubkey} amount={} deadline={deadline_unix} slot-reserved (awaiting award; {} slot(s) free)",
offer.amount,
self.slots.available()
);

Ok(super::store::Claimed::New) => {
    opline!(
        "seller node claimed job_id={job_id} buyer={buyer_pubkey} amount={} deadline={deadline_unix} slot-reserved (awaiting award; {} slot(s) free)",

A grep for deadline bounded to the function body returns three matches: the
parameter declaration at run.rs:4886, that log line, and an unrelated comment at
run.rs:5040. The value is not stored, not published on the claim event, and not
passed to claim_and_enqueue — that call uses its own CLAIM_PUBLISH_WINDOW_SECS
(run.rs:63).

What execution actually enforces

The stored row takes the wire offer's 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 and uses its deadline:

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 hands it to the retry loop and to unified_job_timeout:

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);

So an operator who sets job_timeout_secs sees the number in one claim log line and
nowhere else. The agent still runs under the offer's deadline.

The CLI flag is the same value, not an alternative

--job-timeout-secs writes the config key and stops there:

"--job-timeout-secs" => {
index += 1;
let raw = args
.get(index)
.ok_or_else(|| "missing value for --job-timeout-secs".to_owned())?;
options.job_timeout_secs = Some(
raw.parse()
.map_err(|_| format!("--job-timeout-secs must be a u64, got {raw}"))?,
);
}

let job_timeout_secs = options
.job_timeout_secs
.or_else(|| existing.as_ref().and_then(|seller| seller.job_timeout_secs));

let job_timeout_secs = options
    .job_timeout_secs
    .or_else(|| existing.as_ref().and_then(|seller| seller.job_timeout_secs));

…and that lands in SellerConfig at crates/maxplayer/src/sell.rs#L500. The flag and
the TOML key are one field, so there is no working flag beside a dead key.

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.rs and seller_exec.rs are byte-identical at all four (cmp), so
job_deadline_unix and unified_job_timeout are the same code in every one.
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
job_deadline_unix call 2178 2220 2220
offer_row writes the wire deadline 1785 1785 1785
execution reads the row 5961 6003 6003
unified_job_timeout(deadline, …) 5979 6028 6028
log-only use in claim_offer 4986 5028 5028

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.

Two ways to close this

Either closes it alone, and the choice is a maintainer's:

  1. Wire the value through. Persist the job_deadline_unix result on the job row
    (or thread it to execute_job) so execution enforces the override the docs
    describe. This gives job_timeout_secs the behaviour it advertises.
  2. Remove the override. Delete job_timeout_secs and --job-timeout-secs, and
    correct the two doc comments at home.rs:198 and seller_exec.rs:1812 so they
    describe the offer deadline alone.

We are not proposing which. Option 1 has an interaction with the offer deadline that
belongs in #945; option 2 has none.

What we did not check

  • No build and no test run. This is a static read at four revisions. We did not
    compile a seller, set job_timeout_secs, and observe the timeout.
  • No deployed binary was inspected. We read 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.

Related

#945 asks whether the offer deadline should be split into a claim deadline and a
separate execution budget. It cites this issue as evidence. Neither issue depends
on the other: this one is closeable on its own, by either of the two ways above.

Labels

We cannot set labels on this repository. Requested: bug, seller
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

    Labels

    No labels
    No labels

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions