Skip to content

mcpx(tasks): per-request task TTL has no ceiling — past ~9.22e12 ms the duration overflows and the task deletes/cancels itself at birth; huge values keep results ~forever #2351

Closed

Description

@dezren39

Summary

A caller-supplied task TTL reaches tasks.Store.Start with only a ttl <= 0 → operator default rule and no ceiling, at every entry point:

  • internal/daemon/server.go:893 — /v1 call tasks: s.startCallTask(req.Task.TTL, ...)
  • internal/daemon/routes_exec.go:238-245 — exec tasks: req.Options.Task.TTL (internal/execsvc/execsvc.go:92-93, "milliseconds the result is kept, as MCP spells it")
  • internal/daemon/routes_proto.go:422-425 — proto ask tasks: req.TTL

internal/tasks/tasks.go:355-357 substitutes the default (tasks.ttl, 10m; proto.askTTL, 15m) only when ttl <= 0. Any positive value is accepted verbatim, and tasks.go:389 converts it unsafely:

time.AfterFunc(time.Duration(ttl)*time.Millisecond, func() { ... delete ...; cancel() })

Two failure modes follow:

  1. Overflow → instant self-destruct. time.Duration is int64 nanoseconds, so any ttl > ~9.22e12 ms (~292 years in ms — a plain JSON number like 10000000000000) makes time.Duration(ttl) * time.Millisecond wrap negative. time.AfterFunc with a negative duration fires immediately: the task is deleted from the store and its context is cancelled at birth, before the work can run. The caller gets a 202 handle for a task that can never be collected.
  2. Huge-but-valid → retention the operator never chose. ttl = 1e12 ms (~31.7 years) is stored verbatim, so task results — tool-call and exec results, the payloads the retention exists to bound — are kept effectively forever, silently overriding the operator's tasks.ttl (10m default). This is the same shape as the artifact-PUT ceiling gap in mcpx(daemon): artifact PUT ttl has no ceiling — a per-request TTL overrides the operator's artifacts.ttl (24h) up to ~292 years, so GC never collects the artifact #2327, in the task store instead of the artifact store.

Evidence (main 5a34fa70, laptop probe with the real tasks.Store)

Probe test in laptop worktree ~/git/fleet/probe-2026-10-29-laneB-taskttl/pkgs/mcpx/internal/tasks/probe_taskttl_test.go (go test -run TestProbeTaskTTLOverflow -v):

  • Control ttl=60000: work (150ms) completes, task present with status completed after 400ms.
  • ttl=10000000000000 (1e13): after 400ms the task is gone (Get → not present) — deleted before its 150ms of work finished. Probe asserts this as the reproduced bug and fails as designed.
  • ttl=1000000000000 (1e12): accepted and stored verbatim (stored TTL=1000000000000 ms).

Why it matters

The task handle is the contract for long-running tool/exec calls: the caller polls or collects the result later. An overflowing TTL silently breaks that contract in the worst way — accepted with a 202, then instantly uncollectable, with the work cancelled — and a merely huge TTL defeats the retention bound that keeps results (potentially sensitive tool/exec output) from living in the daemon's store and SQLite DB forever. Both are one JSON field from any local caller, and neither produces an error anywhere.

Acceptance criteria

  • A per-request task TTL is validated at the daemon boundary (or in tasks.Store.Start): values above a defined ceiling are rejected with a 400-class error or clamped to the ceiling — triage pins which, and the ceiling's relationship to the operator's tasks.ttl / proto.askTTL.
  • No accepted TTL can overflow the time.Duration(ttl) * time.Millisecond conversion (regression test at the boundary value, e.g. ttl = 1e13, asserting the task survives its work and completes).
  • The rule applies uniformly to all three entry points (server.go call tasks, routes_exec.go exec tasks, routes_proto.go ask tasks) — a single guard in Start is the obvious way to guarantee that.
  • Existing task tests pass; the persisted expiresAtMs = createdMs + ttlMs load path (tasks.go:210) stays consistent with the pinned rule.

Related: #2327 (the artifact-store sibling: per-request TTL with no ceiling overriding the operator default), #1653 (distinct — legitimate TTLs are clocked from creation, so a task is cancelled mid-run when its TTL lapses; this issue is about unbounded/overflowing TTL values, not the clock start).

Found by new-issue-creator-code-b (lane B) this run.

Activity

  1. developing-today commented on Oct 29, 2026

    @developing-today
    Contributor
  2. added
    kind/bugSomething isn't working (prefixed form of the classic bug label)
    priority/normalOrdinary priority (the default)
    queue/queuedHas a queued task in the Supabase agent queue
    source/creatorFiled by the new-issue-creator job
    task/triageQueue packet kind: verify, reproduce, and route this item
    app/mcpxPackage/app: mcpx (pkgs/ or apps/ in this repo)
    effort/xsTrivial: expected under 30 minutes of work
    on Oct 29, 2026
  3. dezren39 commented on Oct 29, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · filed

    • Agent: new-issue-creator (new-issue-creator-code-b)
    • Session / ID: new-issue-creator-code-b cron run 2026-10-29T14:50Z
    • Found via: filed this run
    • Task: Filed mcpx(tasks): per-request task TTL has no ceiling — past ~9.22e12 ms the duration overflows and the task deletes/cancels itself at birth; huge values keep results ~forever #2351 — caller-supplied task TTL reaches tasks.Store.Start (internal/tasks/tasks.go:355-357) with only a <=0 → operator-default rule at all three entry points (server.go:893 call tasks, routes_exec.go:238-245 exec tasks, routes_proto.go:422-425 ask tasks), and tasks.go:389 converts it as time.Duration(ttl)*time.Millisecond with no ceiling: ttl > ~9.22e12 ms overflows int64 ns, wraps negative, and the AfterFunc expiry deletes the task and cancels its context at birth; ttl=1e12 ms (~31.7y) is stored verbatim, overriding the operator's tasks.ttl (10m). Verified this run (laptop probe, real tasks.Store, main 5a34fa7): control 60s completes; ttl=1e13 task gone before its 150ms work finished; ttl=1e12 stored verbatim.
    • Expectation: effort/xs — triage pins reject-vs-clamp and the ceiling, then routes the fix with the boundary-value regression test.
    • Links: queue packet packet-2351-triage.json (repo-health-code-pilot)
  4. added
    work/assignedQueue worker lifecycle state
    queue/claimedQueue worker lifecycle state
    and removed
    queue/queuedHas a queued task in the Supabase agent queue
    on Nov 1, 2026
  5. dezren39 commented on Nov 1, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · picked up

  6. dezren39 commented on Nov 1, 2026

    @dezren39
    MemberAuthor

    🤖 Agent note · triaged — ALREADY FIXED upstream (clamp rule landed via #2498 / PR #2869); closing with evidence, no follow-up packet

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

Metadata

Metadata

Assignees

Labels

app/mcpxPackage/app: mcpx (pkgs/ or apps/ in this repo)effort/xsTrivial: expected under 30 minutes of workkind/bugSomething isn't working (prefixed form of the classic bug label)priority/normalOrdinary priority (the default)source/creatorFiled by the new-issue-creator jobstatus:donetask/triageQueue packet kind: verify, reproduce, and route this item

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions