{"id":"karlobii/taskrunner","name":"taskrunner","scope":"karlobii","platform":"roblox","description":"A managed Luau coroutine wrapper with lifecycle, priority, timeout, retry and structured result/error propagation.","version":"0.1.0","latest":"0.1.0","versions":["0.1.0-test","0.1.1-test","0.1.0","0.1.2-test"],"license":"MIT","licenseRating":"safe","licenseCaveats":[],"licenseVerified":true,"dependencies":{},"integrity":"d99bec6c5d852cf024e4a2e759e6527297443ecc065949835e9e25e862a4ad8e","likes":0,"downloads":0,"install":"forest install karlobii/taskrunner","url":"https://forest.dev/p/roblox/karlobii/taskrunner","files":"https://api.forest.dev/ai/package/roblox/karlobii/taskrunner/files","readme":"# TaskRunner\n\nA small Wally package that wraps a Luau coroutine with:\n\n- **Lifecycle management** — `Idle → Running → [Retrying] → Completed | Failed | Cancelled | TimedOut`, exposed via `Status` and a `StatusChanged` signal.\n- **Priority** — a numeric priority you set yourself, honored by the bundled `Scheduler` (highest priority runs first, FIFO among ties).\n- **Timeout awareness** — a hard watchdog that cancels the underlying thread, plus a `Handle` your function can poll cooperatively (`handle:CheckTimeout()`, `handle:TimeLeft()`).\n- **Automatic retry** — configurable attempt count, delay, exponential backoff, jitter, and an optional `shouldRetry(err, attempt)` predicate.\n- **Structured result/error propagation** — every run ends in a `Result<T>` table (`{ ok, value, error, attempts, elapsed }`) instead of a bare `pcall` boolean.\n\n## Installation\n\n```toml\n# wally.toml\n[dependencies]\nTaskRunner = \"karlobii/taskrunner@0.1.0\"\n```\n\n## Quick start\n\n```lua\nlocal TaskRunner = require(Packages.TaskRunner)\n\nlocal job = TaskRunner.new(function(handle)\n    handle:CheckTimeout() -- bail out early if we're already over time/cancelled\n\n    local response = httpService:RequestAsync({ Url = \"https://example.com\" })\n    if response.StatusCode ~= 200 then\n        error(\"bad status \" .. response.StatusCode)\n    end\n    return response.Body\nend, {\n    name = \"FetchExample\",\n    priority = TaskRunner.Priority.High,\n    timeout = 5,       -- seconds\n    retries = 3,       -- retry up to 3 times after the first attempt\n    retryDelay = 0.5,  -- base delay between attempts\n    backoff = 2,       -- exponential backoff multiplier\n    jitter = 0.25,     -- up to +0.25s of random jitter added to each delay\n})\n\njob.Completed:Connect(function(result)\n    print(\"got body:\", result.value)\nend)\n\njob.Failed:Connect(function(result)\n    warn((\"failed after %d attempts: %s\"):format(result.attempts, result.error))\nend)\n\njob:Start()\n```\n\nOr synchronously from another coroutine/thread:\n\n```lua\nlocal result = TaskRunner.new(fn, { timeout = 3 }):Start():Await()\nif result.ok then\n    print(result.value)\nelse\n    warn(result.error)\nend\n```\n\n## API\n\n### `TaskRunner.new(fn, options?) -> TaskRunnerInstance`\n\n`fn` is called as `fn(handle)`. Its return value becomes `Result.value` on success; any error thrown becomes `Result.error` on failure (after retries are exhausted).\n\n`options`:\n\n| Field        | Type                                  | Default | Description |\n|--------------|----------------------------------------|---------|-------------|\n| `name`       | `string`                              | `\"TaskRunner\"` | Label, useful in logs/errors. |\n| `priority`   | `number`                              | `TaskRunner.Priority.Normal` | Consumed by `Scheduler`; otherwise informational. |\n| `timeout`    | `number?`                             | `nil` (no timeout) | Seconds before the attempt is force-cancelled. |\n| `retries`    | `number`                              | `0` | Additional attempts after the first failure. |\n| `retryDelay` | `number`                              | `0` | Base delay in seconds before a retry. |\n| `backoff`    | `number`                              | `1` | Multiplier applied to `retryDelay` per attempt (`retryDelay * backoff^(attempt-1)`). |\n| `jitter`     | `number`                              | `0` | Adds `random() * jitter` extra seconds to each retry delay. |\n| `shouldRetry`| `(err: string, attempt: number) -> boolean` | `nil` | Return `false` to stop retrying regardless of `retries` left. |\n\n### Instance members\n\n- `Status` — one of `TaskRunner.Status.{Idle, Running, Retrying, Completed, Failed, Cancelled, TimedOut}`.\n- `Attempts` — number of attempts made so far.\n- `Result` — the last `Result<T>` once terminal, else `nil`.\n- `Completed`, `Failed`, `Cancelled` — `Signal<Result<T>>`.\n- `Retrying` — `Signal<attempt: number, error: string>`, fired before each retry sleep.\n- `StatusChanged` — `Signal<newStatus, oldStatus>`.\n- `:Start() -> self` — begins execution; can only be called once.\n- `:Await() -> Result<T>` — cooperatively waits (via `task.wait`) until terminal, then returns `Result`.\n- `:Cancel(reason: string?)` — cancels immediately from any non-terminal state.\n- `:IsDone() -> boolean`.\n\n### `Handle` (passed into your function)\n\n- `handle:IsCancelled() -> boolean`\n- `handle:TimeLeft() -> number?` — seconds until the deadline, or `nil` if no timeout is set.\n- `handle:CheckTimeout()` — throws if cancelled or past the deadline; call this at loop boundaries in long-running work so timeouts/cancellation are cooperative rather than only enforced by the hard watchdog.\n- `handle.Attempt` — the current attempt number (`1` on the first try).\n\n### `Result<T>`\n\n```lua\n{\n    ok: boolean,\n    value: T?,       -- present when ok == true\n    error: string?,  -- present when ok == false\n    attempts: number,\n    elapsed: number, -- seconds since :Start()\n}\n```\n\n### `TaskRunner.Scheduler`\n\nOptional bounded-concurrency priority queue for running many `TaskRunner`s together.\n\n```lua\nlocal scheduler = TaskRunner.Scheduler.new({ maxConcurrent = 4 })\nscheduler:Add(jobA)\nscheduler:Add(jobB) -- higher-priority jobs run first regardless of add order\nscheduler.Idle:Connect(function()\n    print(\"queue drained\")\nend)\n```\n\n- `Scheduler.new({ maxConcurrent: number? }) -> Scheduler`\n- `:Add(runner) -> runner` — queues an un-started `TaskRunner`; starts it once a concurrency slot frees up.\n- `:CancelAll(reason: string?)` — cancels every queued and in-flight runner.\n- `:RunningCount()`, `:QueuedCount()`.\n- `.Idle` — `Signal<>`, fires whenever the queue empties and nothing is running.\n\n## Design notes\n\n- The hard timeout is enforced with `task.cancel` on the underlying coroutine's thread. This works for any coroutine that yields periodically — e.g. one that calls `task.wait` in a loop — **even if it never calls `handle:CheckTimeout()`**. It cannot interrupt a coroutine that never yields at all (a tight, non-yielding CPU loop): Luau's cooperative scheduling means nothing can preempt code that never hands control back. If your work might spin without yielding, call `handle:CheckTimeout()` inside the loop yourself so it can bail out cooperatively.\n- Retries reuse the same `TaskRunnerInstance` — `Attempts` accumulates across the whole lifecycle, and `Result.attempts` reflects the total attempts made when the runner settles.\n- `TaskRunner:Start()` is one-shot by design; construct a fresh instance if you need to run the same logic again.\n","readmeTruncated":false}