Summary
TaskManager measures an MCP Task's TTL from createdAt rather than from last activity, and the cleanup sweep deletes expired tasks unconditionally. A task that is still legitimately working past its TTL is deleted mid-flight, after which tasks/result has nothing to return.
Reporting progress with ctx.task.updateProgress() does not reset the clock, which is surprising given progress reporting is the documented pattern for long-running work.
Environment
- NitroStack package(s) and version(s):
@nitrostack/core@1.0.14
- Node.js version (
node -v): v24.18.0
- npm version (
npm -v): 11.16.0
- OS and version: macOS 26.2
Steps To Reproduce
- Define a tool with
taskSupport: 'optional' whose handler legitimately runs longer than the TTL (e.g. it waits on an external system or a human).
- Invoke it task-augmented without specifying a TTL, so the default 300000 ms applies.
- Have the handler call
ctx.task.updateProgress(...) periodically while it waits.
- Let it run past 5 minutes, then call
tasks/result.
Expected Behavior
Either the task survives while it is actively reporting progress (TTL behaving as an idle timeout), or there is a documented server-side way for a handler to extend its own deadline.
Actual Behavior
The task is deleted by the cleanup sweep at createdAt + ttl regardless of activity. Retrieving it afterwards throws Task has expired (JSON-RPC −32602), even though the handler is still running.
Minimal Reproduction
@Tool({ name: 'wait_for_human', taskSupport: 'optional', inputSchema: z.object({}) })
async waitForHuman(_input: unknown, ctx: ExecutionContext) {
// Waits on an out-of-band human decision — inherently unbounded.
for (let i = 0; i < 200; i++) {
ctx.task?.updateProgress(`still waiting… (${i * 5}s)`);
await new Promise(r => setTimeout(r, 5000));
}
return { done: true };
}
Additional Context
Relevant implementation (dist/core/task.js):
ttl = params?.ttl ?? 300000
- a cleanup interval runs every 30s deleting tasks where
now - createdAt > ttl
Three things combine to make this sharp:
- Only the client can set the TTL (
task: { ttl }). A server-side handler that knows it needs longer has no way to extend it.
updateProgress() does not reset the clock, so a well-behaved long task that reports progress every second is still deleted at the deadline.
- Task storage is in-memory, so tasks are also lost on restart.
We hit this building a human-in-the-loop approval flow, where the wait is inherently unbounded because it depends on a person responding. We currently cap our wait below the default TTL to avoid it, but that puts an artificial ceiling on how long a human is allowed to take.
For context on why this may not have surfaced before: the sample apps in this repo that use Tasks — seer, Lab_triage_assistant, neurotwin-evolver, Reversible-Agent-Actions — all use them for bounded compute that completes comfortably inside the default TTL. The behaviour only becomes a problem when the wait is unbounded, such as when it depends on a person responding.
Suggested fix: treat the TTL as an idle timeout (reset it on updateProgress), and/or expose a way for the handler to extend its own deadline. Documenting the current semantics on the Tasks page would help in the meantime — the docs describe TTL as "time before a task may be deleted" without noting it is measured from creation rather than last activity.
Summary
TaskManagermeasures an MCP Task's TTL fromcreatedAtrather than from last activity, and the cleanup sweep deletes expired tasks unconditionally. A task that is still legitimately working past its TTL is deleted mid-flight, after whichtasks/resulthas nothing to return.Reporting progress with
ctx.task.updateProgress()does not reset the clock, which is surprising given progress reporting is the documented pattern for long-running work.Environment
@nitrostack/core@1.0.14node -v): v24.18.0npm -v): 11.16.0Steps To Reproduce
taskSupport: 'optional'whose handler legitimately runs longer than the TTL (e.g. it waits on an external system or a human).ctx.task.updateProgress(...)periodically while it waits.tasks/result.Expected Behavior
Either the task survives while it is actively reporting progress (TTL behaving as an idle timeout), or there is a documented server-side way for a handler to extend its own deadline.
Actual Behavior
The task is deleted by the cleanup sweep at
createdAt + ttlregardless of activity. Retrieving it afterwards throwsTask has expired(JSON-RPC −32602), even though the handler is still running.Minimal Reproduction
Additional Context
Relevant implementation (
dist/core/task.js):ttl = params?.ttl ?? 300000now - createdAt > ttlThree things combine to make this sharp:
task: { ttl }). A server-side handler that knows it needs longer has no way to extend it.updateProgress()does not reset the clock, so a well-behaved long task that reports progress every second is still deleted at the deadline.We hit this building a human-in-the-loop approval flow, where the wait is inherently unbounded because it depends on a person responding. We currently cap our wait below the default TTL to avoid it, but that puts an artificial ceiling on how long a human is allowed to take.
For context on why this may not have surfaced before: the sample apps in this repo that use Tasks —
seer,Lab_triage_assistant,neurotwin-evolver,Reversible-Agent-Actions— all use them for bounded compute that completes comfortably inside the default TTL. The behaviour only becomes a problem when the wait is unbounded, such as when it depends on a person responding.Suggested fix: treat the TTL as an idle timeout (reset it on
updateProgress), and/or expose a way for the handler to extend its own deadline. Documenting the current semantics on the Tasks page would help in the meantime — the docs describe TTL as "time before a task may be deleted" without noting it is measured from creation rather than last activity.