@workerdeck/queue 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +106 -0
- package/build/index.d.mts +149 -0
- package/build/index.mjs +620 -0
- package/build/index.mjs.map +1 -0
- package/package.json +56 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tobias Strebitzer
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# @workerdeck/queue
|
|
2
|
+
|
|
3
|
+
Job queue over the WorkerDeck session runner: remote services schedule one-shot runs; the queue
|
|
4
|
+
executes them as ordinary sessions with bounded concurrency and token budgets, delivering progress
|
|
5
|
+
and completion via webhooks. Pluggable adapter interface — in-memory bundled; redis/bullmq/pubsub
|
|
6
|
+
adapters can implement the same contract.
|
|
7
|
+
|
|
8
|
+
Part of [WorkerDeck](https://github.com/workerdeck/workerdeck). It runs jobs through
|
|
9
|
+
[`@workerdeck/core`](https://www.npmjs.com/package/@workerdeck/core)'s `SessionRunner` and is
|
|
10
|
+
usually consumed indirectly: pass the `queue` option to
|
|
11
|
+
[`@workerdeck/server`](https://www.npmjs.com/package/@workerdeck/server) and it mounts
|
|
12
|
+
`/jobs` + `/queue` REST routes plus a `/queue/ws` live stream, with
|
|
13
|
+
[`@workerdeck/client`](https://www.npmjs.com/package/@workerdeck/client) as the caller.
|
|
14
|
+
Use this package directly to embed the queue in a custom host or to write a shared-backend adapter.
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npm install @workerdeck/queue
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Usage
|
|
23
|
+
|
|
24
|
+
A job is **one unattended run**: the session executes `session.prompt`, the first turn result
|
|
25
|
+
completes the job (result, cumulative usage, cost), and the session is closed.
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
import { JobQueue } from '@workerdeck/queue'
|
|
29
|
+
import { SessionRunner } from '@workerdeck/core'
|
|
30
|
+
|
|
31
|
+
const queue = new JobQueue({
|
|
32
|
+
// Typically the server registry's create(), so job sessions are ordinary
|
|
33
|
+
// sessions clients can attach to and watch.
|
|
34
|
+
createRunner: (config) => new SessionRunner(config),
|
|
35
|
+
maxConcurrency: 2,
|
|
36
|
+
sessionTokenLimit: 200_000, // per-job cap (input+output+cache); exceeding kills the run
|
|
37
|
+
dailyTokenLimit: 2_000_000, // global UTC-day budget; queued jobs held once exhausted
|
|
38
|
+
maxJobDurationMs: 1_800_000, // wall-clock watchdog for stuck CLIs
|
|
39
|
+
retention: { maxAgeMs: 86_400_000 }, // expire terminal jobs
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
const job = await queue.submit({
|
|
43
|
+
session: { cwd: '/srv/checkout', prompt: '/verify-content 42' },
|
|
44
|
+
webhook: { url: 'https://my-app.test/hooks/claude', headers: { authorization: '…' } },
|
|
45
|
+
attempts: 3, // failed (not canceled) runs re-queue with exponential backoff
|
|
46
|
+
})
|
|
47
|
+
// job_submitted → job_started → job_progress → job_retrying? → job_completed
|
|
48
|
+
// arrive at the webhook (ordered per job, delivery retried with backoff).
|
|
49
|
+
|
|
50
|
+
await queue.get(job.id) // JobInfo | null
|
|
51
|
+
await queue.stats() // { running, queued, dailyTokensUsed, paused, … }
|
|
52
|
+
await queue.cancel(job.id)
|
|
53
|
+
queue.close() // stop scheduling; job state stays in the adapter
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### The `QueueAdapter` contract
|
|
57
|
+
|
|
58
|
+
Job state lives behind the `QueueAdapter` interface: `add`, `claimNext`, `get`, `list`, `update`,
|
|
59
|
+
`prune`, `addDailyTokens`/`dailyTokens`, and an optional `onWork` wakeup for shared backends.
|
|
60
|
+
Two rules matter when implementing one:
|
|
61
|
+
|
|
62
|
+
- `claimNext()` must be **atomic** across workers — two concurrent claims must never return the
|
|
63
|
+
same job — and must skip queued jobs whose `nextRunAt` is still in the future (retry backoff).
|
|
64
|
+
- Daily token counters live in the adapter (keyed by UTC `YYYY-MM-DD`), so budgets hold across
|
|
65
|
+
multiple workers sharing a backend.
|
|
66
|
+
|
|
67
|
+
The bundled `InMemoryQueueAdapter` is single-process and non-persistent: jobs and daily counters
|
|
68
|
+
reset on restart. Back the queue with a shared store for anything beyond one trusted host.
|
|
69
|
+
|
|
70
|
+
### Runs that park
|
|
71
|
+
|
|
72
|
+
A job whose session is waiting on a deferred execution does not sit and hold a slot. The session
|
|
73
|
+
parks — its state is snapshotted, its runner torn down — and the job goes `parked`, emitting
|
|
74
|
+
`job_parked` with the `executionId` it waits on. It keeps its attempt, its accumulated usage, and
|
|
75
|
+
its place, but frees its concurrency slot and stops its wall-clock clock; `job_resumed` fires when
|
|
76
|
+
the result lands. One worker can therefore have a hundred runs waiting on the world and still run
|
|
77
|
+
only three at a time.
|
|
78
|
+
|
|
79
|
+
`parked` is not terminal anywhere: `claimNext` never claims one, retention never prunes one, and
|
|
80
|
+
cancelling one discards its snapshot so nothing can wake it. `maxParkedDurationMs` caps total
|
|
81
|
+
parked time across all parks of a run, and `QueueStats.parked` / `JobInfo.parkedAt` /
|
|
82
|
+
`JobInfo.parkedExecutionId` report what is waiting on what. A park that may outlive the process
|
|
83
|
+
needs a durable session store on the server side.
|
|
84
|
+
|
|
85
|
+
## Options at a glance
|
|
86
|
+
|
|
87
|
+
| Option | Default | Effect |
|
|
88
|
+
| --- | --- | --- |
|
|
89
|
+
| `maxConcurrency` | 1 | Concurrent job sessions. |
|
|
90
|
+
| `sessionTokenLimit` | off | Token cap per job run; exceeding interrupts and fails the job. |
|
|
91
|
+
| `dailyTokenLimit` | off | Global budget per UTC day; queued jobs held until rollover. |
|
|
92
|
+
| `maxJobDurationMs` | off | Wall-clock cap per run — the watchdog for stuck CLIs. |
|
|
93
|
+
| `killGraceMs` | 5000 | Wind-down after a kill before the run is force-finalized. |
|
|
94
|
+
| `retention` | keep forever | Prune terminal jobs older than `maxAgeMs` (periodic sweep). |
|
|
95
|
+
| `webhookAttempts` / `webhookRetryDelayMs` | 3 / 500ms | Delivery retries per event, exponential backoff. |
|
|
96
|
+
| `buildRunnerConfig` | identity | Patch job session configs (env, tool policy) before they run. |
|
|
97
|
+
| `onEvent` | — | Local observer for every `JobEvent`, in addition to any webhook. |
|
|
98
|
+
|
|
99
|
+
Per-request, `CreateJobRequest` adds `attempts`, `retryDelayMs`, `maxTokens`, `maxDurationMs`
|
|
100
|
+
(the stricter of request and queue limits wins), `webhook.progress: 'completion'` to quiet
|
|
101
|
+
progress deliveries, and free-form `meta`.
|
|
102
|
+
|
|
103
|
+
## License
|
|
104
|
+
|
|
105
|
+
MIT © Tobias Strebitzer — see
|
|
106
|
+
[LICENSE](https://github.com/workerdeck/workerdeck/blob/master/LICENSE).
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { CreateJobRequest, CreateSessionRequest, JobEvent, JobInfo, QueueStats } from "@workerdeck/protocol";
|
|
2
|
+
import { Runner, SessionRunnerConfig } from "@workerdeck/core";
|
|
3
|
+
|
|
4
|
+
//#region src/adapter.d.ts
|
|
5
|
+
/** A job as the adapter stores it: the wire-visible info plus the original request. */
|
|
6
|
+
type JobRecord = {
|
|
7
|
+
info: JobInfo;
|
|
8
|
+
request: CreateJobRequest;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Storage + claiming contract the JobQueue runs against. The bundled implementation
|
|
12
|
+
* is {@link InMemoryQueueAdapter}; redis/bullmq/pubsub adapters implement the same
|
|
13
|
+
* interface. Everything is Promise-based so remote backends fit without changing the
|
|
14
|
+
* queue, and `claimNext` is the one operation that must be atomic across workers
|
|
15
|
+
* (two concurrent claims must never return the same job).
|
|
16
|
+
*/
|
|
17
|
+
interface QueueAdapter {
|
|
18
|
+
/** Persist a newly submitted job (status 'queued'). */
|
|
19
|
+
add(job: JobRecord): Promise<void>;
|
|
20
|
+
/**
|
|
21
|
+
* Atomically claim the oldest claimable queued job, transitioning it to 'running'.
|
|
22
|
+
* A queued job whose `nextRunAt` is in the future (retry backoff) is not claimable
|
|
23
|
+
* yet. Returns null when nothing is claimable.
|
|
24
|
+
*/
|
|
25
|
+
claimNext(): Promise<JobRecord | null>;
|
|
26
|
+
get(id: string): Promise<JobRecord | null>;
|
|
27
|
+
/** All known jobs, oldest first. Backends may cap retention of terminal jobs. */
|
|
28
|
+
list(): Promise<JobRecord[]>;
|
|
29
|
+
/** Merge a partial info patch into a job (a key explicitly set to undefined clears
|
|
30
|
+
* that field). Returns the updated record, or null if unknown. */
|
|
31
|
+
update(id: string, patch: Partial<JobInfo>): Promise<JobRecord | null>;
|
|
32
|
+
/** Delete terminal jobs (succeeded/failed/canceled) that finished more than
|
|
33
|
+
* `olderThanMs` ago. Returns how many were removed. */
|
|
34
|
+
prune(olderThanMs: number): Promise<number>;
|
|
35
|
+
/**
|
|
36
|
+
* Add tokens to a day's global counter and return the new total. `dayKey` is a UTC
|
|
37
|
+
* 'YYYY-MM-DD'; keeping the counter in the adapter makes daily budgets hold across
|
|
38
|
+
* multiple workers sharing a backend.
|
|
39
|
+
*/
|
|
40
|
+
addDailyTokens(dayKey: string, tokens: number): Promise<number>;
|
|
41
|
+
dailyTokens(dayKey: string): Promise<number>;
|
|
42
|
+
/**
|
|
43
|
+
* Optional: notify the queue that work may be available (a job added by another
|
|
44
|
+
* producer on a shared backend). The bundled queue also pumps after its own
|
|
45
|
+
* submits/completions, so purely local adapters can omit this.
|
|
46
|
+
*/
|
|
47
|
+
onWork?(listener: () => void): () => void;
|
|
48
|
+
}
|
|
49
|
+
/** Reference adapter: single-process, no persistence. Jobs and daily counters are lost
|
|
50
|
+
* on restart — production deployments should back the queue with a shared store. */
|
|
51
|
+
declare class InMemoryQueueAdapter implements QueueAdapter {
|
|
52
|
+
#private;
|
|
53
|
+
add(job: JobRecord): Promise<void>;
|
|
54
|
+
claimNext(): Promise<JobRecord | null>;
|
|
55
|
+
get(id: string): Promise<JobRecord | null>;
|
|
56
|
+
list(): Promise<JobRecord[]>;
|
|
57
|
+
update(id: string, patch: Partial<JobInfo>): Promise<JobRecord | null>;
|
|
58
|
+
prune(olderThanMs: number): Promise<number>;
|
|
59
|
+
addDailyTokens(dayKey: string, tokens: number): Promise<number>;
|
|
60
|
+
dailyTokens(dayKey: string): Promise<number>;
|
|
61
|
+
}
|
|
62
|
+
//#endregion
|
|
63
|
+
//#region src/queue.d.ts
|
|
64
|
+
type JobQueueOptions = {
|
|
65
|
+
/** Turn a session config into a live runner — typically the server registry's create(),
|
|
66
|
+
* so job sessions are ordinary sessions clients can attach to and watch. May be
|
|
67
|
+
* async: engines whose assembly awaits (a provider session's MCP connect) resolve
|
|
68
|
+
* here, and a rejection fails the job like any other start error. */
|
|
69
|
+
createRunner: (config: SessionRunnerConfig) => Runner | Promise<Runner>; /** Storage/claiming backend. Defaults to the in-memory adapter (single process). */
|
|
70
|
+
adapter?: QueueAdapter; /** Concurrent job sessions. Default 1. */
|
|
71
|
+
maxConcurrency?: number; /** Token cap per job session; exceeding it interrupts the run and fails the job. */
|
|
72
|
+
sessionTokenLimit?: number;
|
|
73
|
+
/** Global token budget per UTC day; when exhausted, queued jobs are held until the
|
|
74
|
+
* day rolls over (running jobs finish and are accounted). */
|
|
75
|
+
dailyTokenLimit?: number;
|
|
76
|
+
/** Wall-clock cap per job run; exceeding it interrupts the run and fails the job.
|
|
77
|
+
* The watchdog for stuck CLIs — without it, a run that never yields a result keeps
|
|
78
|
+
* its job (and concurrency slot) forever. Time spent parked on a deferred
|
|
79
|
+
* execution does not count against it: the run isn't stuck, it's waiting. */
|
|
80
|
+
maxJobDurationMs?: number;
|
|
81
|
+
/** Cap on time parked on a deferred execution, across all parks of one run.
|
|
82
|
+
* Exceeding it fails the job (the execution's own watchdog, when the backend set
|
|
83
|
+
* one, usually fires first and lets the agent adapt instead). Unset = unbounded. */
|
|
84
|
+
maxParkedDurationMs?: number;
|
|
85
|
+
/** How long a killed run (token/duration limit) may wind down after interrupt()
|
|
86
|
+
* before the queue force-finalizes it and closes the session. Default 5000. */
|
|
87
|
+
killGraceMs?: number;
|
|
88
|
+
/** Expire terminal jobs: prune those finished more than `maxAgeMs` ago, sweeping
|
|
89
|
+
* every `sweepIntervalMs` (default min(maxAgeMs, 60s)) and after each completion.
|
|
90
|
+
* Unset = keep forever (the in-memory adapter then grows unboundedly). */
|
|
91
|
+
retention?: {
|
|
92
|
+
maxAgeMs: number;
|
|
93
|
+
sweepIntervalMs?: number;
|
|
94
|
+
}; /** Patch job session configs (inject queryFn, env, tool policy) before they run. */
|
|
95
|
+
buildRunnerConfig?: (req: CreateSessionRequest) => SessionRunnerConfig;
|
|
96
|
+
/**
|
|
97
|
+
* Drop a parked session's persisted state: the run ended (cancel, kill, retry)
|
|
98
|
+
* while parked, so nothing will ever rehydrate it. The host that parks sessions
|
|
99
|
+
* — {@link JobQueue.onSessionParking}'s caller — wires this to its session store.
|
|
100
|
+
*/
|
|
101
|
+
discardSession?: (sessionId: string) => void | Promise<void>; /** Webhook transport. Defaults to global fetch. */
|
|
102
|
+
fetchImpl?: typeof fetch; /** Webhook delivery attempts per event (exponential backoff). Default 3. */
|
|
103
|
+
webhookAttempts?: number; /** Initial backoff between webhook attempts. Default 500ms. */
|
|
104
|
+
webhookRetryDelayMs?: number; /** Local observer invoked for every job event (in addition to any webhook). */
|
|
105
|
+
onEvent?: (event: JobEvent) => void;
|
|
106
|
+
};
|
|
107
|
+
/**
|
|
108
|
+
* One-shot job execution over the session runner: submitted jobs run `session.prompt`
|
|
109
|
+
* unattended, bounded by `maxConcurrency` and token budgets, and report progress and
|
|
110
|
+
* completion through webhooks (plus `onEvent` locally). Job state lives in the
|
|
111
|
+
* {@link QueueAdapter}; this class owns scheduling and the live runs.
|
|
112
|
+
*/
|
|
113
|
+
declare class JobQueue {
|
|
114
|
+
#private;
|
|
115
|
+
constructor(options: JobQueueOptions);
|
|
116
|
+
submit(request: CreateJobRequest): Promise<JobInfo>;
|
|
117
|
+
get(id: string): Promise<JobInfo | null>;
|
|
118
|
+
list(): Promise<JobInfo[]>;
|
|
119
|
+
/**
|
|
120
|
+
* A run's session is about to be parked on a deferred execution: the host has
|
|
121
|
+
* snapshotted it and is tearing the live runner down. The queue drops its
|
|
122
|
+
* subscription to the doomed runner, frees the concurrency slot, and stops the
|
|
123
|
+
* duration clock.
|
|
124
|
+
*
|
|
125
|
+
* Returns false when the queue refuses the park — the run is already finalizing
|
|
126
|
+
* or has been killed, so the host must leave the session alone. A session that
|
|
127
|
+
* belongs to no job accepts trivially (there is nothing to account for).
|
|
128
|
+
*/
|
|
129
|
+
onSessionParking(sessionId: string, executionId: string): boolean;
|
|
130
|
+
/**
|
|
131
|
+
* The parked session was rehydrated (same session id, new runner object) because
|
|
132
|
+
* its execution's result arrived. Re-subscribe and restart the clock with the
|
|
133
|
+
* budget the run had left.
|
|
134
|
+
*
|
|
135
|
+
* A resume takes its slot back immediately, so a burst of resumes can transiently
|
|
136
|
+
* exceed `maxConcurrency` — the alternative would be holding a result the agent
|
|
137
|
+
* loop has already been handed.
|
|
138
|
+
*/
|
|
139
|
+
onSessionResumed(sessionId: string, runner: Runner): void;
|
|
140
|
+
/** Cancel a queued, running, or parked job. Returns the job, or null if unknown. */
|
|
141
|
+
cancel(id: string): Promise<JobInfo | null>;
|
|
142
|
+
stats(): Promise<QueueStats>;
|
|
143
|
+
/** Stop scheduling new jobs. Running jobs keep finalizing (e.g. when the host closes
|
|
144
|
+
* their sessions); job state stays in the adapter. */
|
|
145
|
+
close(): void;
|
|
146
|
+
}
|
|
147
|
+
//#endregion
|
|
148
|
+
export { InMemoryQueueAdapter, JobQueue, type JobQueueOptions, type JobRecord, type QueueAdapter };
|
|
149
|
+
//# sourceMappingURL=index.d.mts.map
|
package/build/index.mjs
ADDED
|
@@ -0,0 +1,620 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
//#region src/adapter.ts
|
|
3
|
+
/** Reference adapter: single-process, no persistence. Jobs and daily counters are lost
|
|
4
|
+
* on restart — production deployments should back the queue with a shared store. */
|
|
5
|
+
var InMemoryQueueAdapter = class {
|
|
6
|
+
#jobs = /* @__PURE__ */ new Map();
|
|
7
|
+
#dailyTokens = /* @__PURE__ */ new Map();
|
|
8
|
+
add(job) {
|
|
9
|
+
this.#jobs.set(job.info.id, job);
|
|
10
|
+
return Promise.resolve();
|
|
11
|
+
}
|
|
12
|
+
claimNext() {
|
|
13
|
+
const now = Date.now();
|
|
14
|
+
for (const job of this.#jobs.values()) if (job.info.status === "queued" && (job.info.nextRunAt === void 0 || job.info.nextRunAt <= now)) {
|
|
15
|
+
job.info = {
|
|
16
|
+
...job.info,
|
|
17
|
+
status: "running"
|
|
18
|
+
};
|
|
19
|
+
return Promise.resolve(job);
|
|
20
|
+
}
|
|
21
|
+
return Promise.resolve(null);
|
|
22
|
+
}
|
|
23
|
+
get(id) {
|
|
24
|
+
return Promise.resolve(this.#jobs.get(id) ?? null);
|
|
25
|
+
}
|
|
26
|
+
list() {
|
|
27
|
+
return Promise.resolve([...this.#jobs.values()]);
|
|
28
|
+
}
|
|
29
|
+
update(id, patch) {
|
|
30
|
+
const job = this.#jobs.get(id);
|
|
31
|
+
if (!job) return Promise.resolve(null);
|
|
32
|
+
job.info = {
|
|
33
|
+
...job.info,
|
|
34
|
+
...patch
|
|
35
|
+
};
|
|
36
|
+
return Promise.resolve(job);
|
|
37
|
+
}
|
|
38
|
+
prune(olderThanMs) {
|
|
39
|
+
const cutoff = Date.now() - olderThanMs;
|
|
40
|
+
let removed = 0;
|
|
41
|
+
for (const [id, job] of this.#jobs) {
|
|
42
|
+
const { status, finishedAt } = job.info;
|
|
43
|
+
if ((status === "succeeded" || status === "failed" || status === "canceled") && (finishedAt ?? 0) <= cutoff) {
|
|
44
|
+
this.#jobs.delete(id);
|
|
45
|
+
removed++;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return Promise.resolve(removed);
|
|
49
|
+
}
|
|
50
|
+
addDailyTokens(dayKey, tokens) {
|
|
51
|
+
const next = (this.#dailyTokens.get(dayKey) ?? 0) + tokens;
|
|
52
|
+
this.#dailyTokens.set(dayKey, next);
|
|
53
|
+
return Promise.resolve(next);
|
|
54
|
+
}
|
|
55
|
+
dailyTokens(dayKey) {
|
|
56
|
+
return Promise.resolve(this.#dailyTokens.get(dayKey) ?? 0);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
//#endregion
|
|
60
|
+
//#region src/queue.ts
|
|
61
|
+
const dayKey = (epochMs) => new Date(epochMs).toISOString().slice(0, 10);
|
|
62
|
+
const sumUsage = (usage) => {
|
|
63
|
+
if (typeof usage !== "object" || usage === null) return 0;
|
|
64
|
+
const u = usage;
|
|
65
|
+
return (typeof u.input_tokens === "number" ? u.input_tokens : 0) + (typeof u.output_tokens === "number" ? u.output_tokens : 0) + (typeof u.cache_creation_input_tokens === "number" ? u.cache_creation_input_tokens : 0) + (typeof u.cache_read_input_tokens === "number" ? u.cache_read_input_tokens : 0);
|
|
66
|
+
};
|
|
67
|
+
const textPreview = (message, max = 140) => {
|
|
68
|
+
const blocks = typeof message.content === "string" ? [{
|
|
69
|
+
type: "text",
|
|
70
|
+
text: message.content
|
|
71
|
+
}] : message.content;
|
|
72
|
+
for (const block of blocks) {
|
|
73
|
+
if (block.type === "tool_use") return {
|
|
74
|
+
kind: "tool_use",
|
|
75
|
+
preview: block.name
|
|
76
|
+
};
|
|
77
|
+
if (block.type === "text") {
|
|
78
|
+
const text = block.text ?? "";
|
|
79
|
+
if (text.trim()) return {
|
|
80
|
+
kind: "assistant_text",
|
|
81
|
+
preview: text.length > max ? text.slice(0, max - 1) + "…" : text
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return null;
|
|
86
|
+
};
|
|
87
|
+
/**
|
|
88
|
+
* One-shot job execution over the session runner: submitted jobs run `session.prompt`
|
|
89
|
+
* unattended, bounded by `maxConcurrency` and token budgets, and report progress and
|
|
90
|
+
* completion through webhooks (plus `onEvent` locally). Job state lives in the
|
|
91
|
+
* {@link QueueAdapter}; this class owns scheduling and the live runs.
|
|
92
|
+
*/
|
|
93
|
+
var JobQueue = class {
|
|
94
|
+
#options;
|
|
95
|
+
#adapter;
|
|
96
|
+
#running = /* @__PURE__ */ new Map();
|
|
97
|
+
/** Runs waiting on a deferred execution: alive, but holding no concurrency slot
|
|
98
|
+
* and no live runner. Keyed by job id like `#running`. */
|
|
99
|
+
#parked = /* @__PURE__ */ new Map();
|
|
100
|
+
#pumping = false;
|
|
101
|
+
#closed = false;
|
|
102
|
+
#offWork;
|
|
103
|
+
#sweepTimer;
|
|
104
|
+
/** Pending retry-backoff wakeups, cleared on close(). */
|
|
105
|
+
#retryTimers = /* @__PURE__ */ new Set();
|
|
106
|
+
constructor(options) {
|
|
107
|
+
this.#options = options;
|
|
108
|
+
this.#adapter = options.adapter ?? new InMemoryQueueAdapter();
|
|
109
|
+
this.#offWork = this.#adapter.onWork?.(() => void this.#pump());
|
|
110
|
+
const retention = options.retention;
|
|
111
|
+
if (retention) {
|
|
112
|
+
const interval = retention.sweepIntervalMs ?? Math.min(retention.maxAgeMs, 6e4);
|
|
113
|
+
this.#sweepTimer = setInterval(() => this.#sweep(), interval);
|
|
114
|
+
this.#sweepTimer.unref?.();
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
async submit(request) {
|
|
118
|
+
if (this.#closed) throw new Error("queue is closed");
|
|
119
|
+
if (!request.session?.prompt?.trim()) throw new Error("session.prompt is required");
|
|
120
|
+
if (!request.session.cwd) throw new Error("session.cwd is required");
|
|
121
|
+
if (request.session.resume || request.session.forkSession) throw new Error("resume/forkSession are not supported for queued jobs");
|
|
122
|
+
const attempts = request.attempts ?? 1;
|
|
123
|
+
if (!Number.isInteger(attempts) || attempts < 1) throw new Error("attempts must be a positive integer");
|
|
124
|
+
if (request.retryDelayMs !== void 0 && !(request.retryDelayMs >= 0)) throw new Error("retryDelayMs must be >= 0");
|
|
125
|
+
const info = {
|
|
126
|
+
id: randomUUID(),
|
|
127
|
+
status: "queued",
|
|
128
|
+
cwd: request.session.cwd,
|
|
129
|
+
profile: request.session.profile,
|
|
130
|
+
prompt: request.session.prompt,
|
|
131
|
+
createdAt: Date.now(),
|
|
132
|
+
attempt: 1,
|
|
133
|
+
maxAttempts: attempts,
|
|
134
|
+
usage: {
|
|
135
|
+
tokens: 0,
|
|
136
|
+
totalCostUsd: 0,
|
|
137
|
+
numTurns: 0
|
|
138
|
+
},
|
|
139
|
+
meta: request.meta
|
|
140
|
+
};
|
|
141
|
+
const record = {
|
|
142
|
+
info,
|
|
143
|
+
request
|
|
144
|
+
};
|
|
145
|
+
await this.#adapter.add(record);
|
|
146
|
+
this.#emit(record, {
|
|
147
|
+
type: "job_submitted",
|
|
148
|
+
job: info,
|
|
149
|
+
ts: Date.now()
|
|
150
|
+
}, void 0, { skipWebhook: true });
|
|
151
|
+
this.#pump();
|
|
152
|
+
return info;
|
|
153
|
+
}
|
|
154
|
+
async get(id) {
|
|
155
|
+
return (await this.#adapter.get(id))?.info ?? null;
|
|
156
|
+
}
|
|
157
|
+
async list() {
|
|
158
|
+
return (await this.#adapter.list()).map((j) => j.info);
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* A run's session is about to be parked on a deferred execution: the host has
|
|
162
|
+
* snapshotted it and is tearing the live runner down. The queue drops its
|
|
163
|
+
* subscription to the doomed runner, frees the concurrency slot, and stops the
|
|
164
|
+
* duration clock.
|
|
165
|
+
*
|
|
166
|
+
* Returns false when the queue refuses the park — the run is already finalizing
|
|
167
|
+
* or has been killed, so the host must leave the session alone. A session that
|
|
168
|
+
* belongs to no job accepts trivially (there is nothing to account for).
|
|
169
|
+
*/
|
|
170
|
+
onSessionParking(sessionId, executionId) {
|
|
171
|
+
const job = this.#bySession(this.#running, sessionId);
|
|
172
|
+
if (!job) return true;
|
|
173
|
+
if (job.finalized || job.killReason) return false;
|
|
174
|
+
const now = Date.now();
|
|
175
|
+
job.unsubscribe();
|
|
176
|
+
job.runningMs += now - job.legStartedAt;
|
|
177
|
+
clearTimeout(job.durationTimer);
|
|
178
|
+
job.durationTimer = void 0;
|
|
179
|
+
job.parkedAt = now;
|
|
180
|
+
job.parkedExecutionId = executionId;
|
|
181
|
+
this.#running.delete(job.record.info.id);
|
|
182
|
+
this.#parked.set(job.record.info.id, job);
|
|
183
|
+
const parkedLimit = this.#options.maxParkedDurationMs;
|
|
184
|
+
if (parkedLimit !== void 0) {
|
|
185
|
+
job.parkTimer = setTimeout(() => this.#kill(job, `job exceeded max parked duration (${parkedLimit}ms)`), Math.max(0, parkedLimit - job.parkedMs));
|
|
186
|
+
job.parkTimer.unref?.();
|
|
187
|
+
}
|
|
188
|
+
this.#recordPark(job, executionId);
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
191
|
+
async #recordPark(job, executionId) {
|
|
192
|
+
const updated = await this.#adapter.update(job.record.info.id, {
|
|
193
|
+
status: "parked",
|
|
194
|
+
parkedAt: job.parkedAt,
|
|
195
|
+
parkedExecutionId: executionId
|
|
196
|
+
});
|
|
197
|
+
if (updated) job.record = updated;
|
|
198
|
+
this.#emit(job.record, {
|
|
199
|
+
type: "job_parked",
|
|
200
|
+
job: job.record.info,
|
|
201
|
+
executionId,
|
|
202
|
+
ts: Date.now()
|
|
203
|
+
}, job);
|
|
204
|
+
this.#pump();
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* The parked session was rehydrated (same session id, new runner object) because
|
|
208
|
+
* its execution's result arrived. Re-subscribe and restart the clock with the
|
|
209
|
+
* budget the run had left.
|
|
210
|
+
*
|
|
211
|
+
* A resume takes its slot back immediately, so a burst of resumes can transiently
|
|
212
|
+
* exceed `maxConcurrency` — the alternative would be holding a result the agent
|
|
213
|
+
* loop has already been handed.
|
|
214
|
+
*/
|
|
215
|
+
onSessionResumed(sessionId, runner) {
|
|
216
|
+
const job = this.#bySession(this.#parked, sessionId);
|
|
217
|
+
if (!job || job.finalized) return;
|
|
218
|
+
const now = Date.now();
|
|
219
|
+
const executionId = job.parkedExecutionId ?? "";
|
|
220
|
+
clearTimeout(job.parkTimer);
|
|
221
|
+
job.parkTimer = void 0;
|
|
222
|
+
job.parkedMs += now - (job.parkedAt ?? now);
|
|
223
|
+
job.parkedAt = void 0;
|
|
224
|
+
job.parkedExecutionId = void 0;
|
|
225
|
+
job.legStartedAt = now;
|
|
226
|
+
job.runner = runner;
|
|
227
|
+
this.#parked.delete(job.record.info.id);
|
|
228
|
+
this.#running.set(job.record.info.id, job);
|
|
229
|
+
const durationLimit = this.#effectiveDurationLimit(job.record.request);
|
|
230
|
+
if (durationLimit !== void 0) {
|
|
231
|
+
job.durationTimer = setTimeout(() => this.#kill(job, `job exceeded max duration (${durationLimit}ms)`), Math.max(0, durationLimit - job.runningMs));
|
|
232
|
+
job.durationTimer.unref?.();
|
|
233
|
+
}
|
|
234
|
+
job.unsubscribe = runner.subscribe((event) => void this.#handleEvent(job, event), job.lastSeq);
|
|
235
|
+
this.#recordResume(job, executionId);
|
|
236
|
+
}
|
|
237
|
+
async #recordResume(job, executionId) {
|
|
238
|
+
const updated = await this.#adapter.update(job.record.info.id, {
|
|
239
|
+
status: "running",
|
|
240
|
+
parkedAt: void 0,
|
|
241
|
+
parkedExecutionId: void 0
|
|
242
|
+
});
|
|
243
|
+
if (updated) job.record = updated;
|
|
244
|
+
this.#emit(job.record, {
|
|
245
|
+
type: "job_resumed",
|
|
246
|
+
job: job.record.info,
|
|
247
|
+
executionId,
|
|
248
|
+
ts: Date.now()
|
|
249
|
+
}, job);
|
|
250
|
+
}
|
|
251
|
+
#bySession(jobs, sessionId) {
|
|
252
|
+
for (const job of jobs.values()) if (job.record.info.sessionId === sessionId) return job;
|
|
253
|
+
}
|
|
254
|
+
/** Cancel a queued, running, or parked job. Returns the job, or null if unknown. */
|
|
255
|
+
async cancel(id) {
|
|
256
|
+
const record = await this.#adapter.get(id);
|
|
257
|
+
if (!record) return null;
|
|
258
|
+
const running = this.#running.get(id) ?? this.#parked.get(id);
|
|
259
|
+
if (running) {
|
|
260
|
+
running.canceled = true;
|
|
261
|
+
running.killReason = "canceled";
|
|
262
|
+
await this.#finalize(running, {
|
|
263
|
+
usage: {
|
|
264
|
+
tokens: running.estimatedTokens,
|
|
265
|
+
totalCostUsd: 0,
|
|
266
|
+
numTurns: 0
|
|
267
|
+
},
|
|
268
|
+
status: "canceled",
|
|
269
|
+
error: "canceled"
|
|
270
|
+
});
|
|
271
|
+
return running.record.info;
|
|
272
|
+
}
|
|
273
|
+
if (record.info.status !== "queued") return record.info;
|
|
274
|
+
const updated = await this.#adapter.update(id, {
|
|
275
|
+
status: "canceled",
|
|
276
|
+
finishedAt: Date.now(),
|
|
277
|
+
error: "canceled"
|
|
278
|
+
});
|
|
279
|
+
if (updated) this.#emit(updated, {
|
|
280
|
+
type: "job_completed",
|
|
281
|
+
job: updated.info,
|
|
282
|
+
ts: Date.now()
|
|
283
|
+
});
|
|
284
|
+
return updated?.info ?? null;
|
|
285
|
+
}
|
|
286
|
+
async stats() {
|
|
287
|
+
const jobs = await this.#adapter.list();
|
|
288
|
+
const dailyTokensUsed = await this.#adapter.dailyTokens(dayKey(Date.now()));
|
|
289
|
+
const dailyTokenLimit = this.#options.dailyTokenLimit;
|
|
290
|
+
return {
|
|
291
|
+
maxConcurrency: this.#options.maxConcurrency ?? 1,
|
|
292
|
+
running: this.#running.size,
|
|
293
|
+
parked: this.#parked.size,
|
|
294
|
+
queued: jobs.filter((j) => j.info.status === "queued").length,
|
|
295
|
+
sessionTokenLimit: this.#options.sessionTokenLimit,
|
|
296
|
+
dailyTokenLimit,
|
|
297
|
+
dailyTokensUsed,
|
|
298
|
+
paused: dailyTokenLimit !== void 0 && dailyTokensUsed >= dailyTokenLimit
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
/** Stop scheduling new jobs. Running jobs keep finalizing (e.g. when the host closes
|
|
302
|
+
* their sessions); job state stays in the adapter. */
|
|
303
|
+
close() {
|
|
304
|
+
this.#closed = true;
|
|
305
|
+
this.#offWork?.();
|
|
306
|
+
clearInterval(this.#sweepTimer);
|
|
307
|
+
for (const timer of this.#retryTimers) clearTimeout(timer);
|
|
308
|
+
this.#retryTimers.clear();
|
|
309
|
+
}
|
|
310
|
+
#sweep() {
|
|
311
|
+
const retention = this.#options.retention;
|
|
312
|
+
if (!retention) return;
|
|
313
|
+
this.#adapter.prune(retention.maxAgeMs).catch(() => {});
|
|
314
|
+
}
|
|
315
|
+
async #pump() {
|
|
316
|
+
if (this.#pumping || this.#closed) return;
|
|
317
|
+
this.#pumping = true;
|
|
318
|
+
try {
|
|
319
|
+
const maxConcurrency = this.#options.maxConcurrency ?? 1;
|
|
320
|
+
while (this.#running.size < maxConcurrency) {
|
|
321
|
+
const limit = this.#options.dailyTokenLimit;
|
|
322
|
+
if (limit !== void 0 && await this.#adapter.dailyTokens(dayKey(Date.now())) >= limit) return;
|
|
323
|
+
const record = await this.#adapter.claimNext();
|
|
324
|
+
if (!record) return;
|
|
325
|
+
await this.#start(record);
|
|
326
|
+
}
|
|
327
|
+
} finally {
|
|
328
|
+
this.#pumping = false;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
async #start(record) {
|
|
332
|
+
const id = record.info.id;
|
|
333
|
+
const build = this.#options.buildRunnerConfig ?? ((req) => req);
|
|
334
|
+
let runner;
|
|
335
|
+
try {
|
|
336
|
+
runner = await this.#options.createRunner(build(record.request.session));
|
|
337
|
+
} catch (error) {
|
|
338
|
+
const failed = await this.#adapter.update(id, {
|
|
339
|
+
status: "failed",
|
|
340
|
+
finishedAt: Date.now(),
|
|
341
|
+
error: error instanceof Error ? error.message : String(error)
|
|
342
|
+
});
|
|
343
|
+
if (failed) this.#emit(failed, {
|
|
344
|
+
type: "job_completed",
|
|
345
|
+
job: failed.info,
|
|
346
|
+
ts: Date.now()
|
|
347
|
+
});
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
const job = {
|
|
351
|
+
record,
|
|
352
|
+
runner,
|
|
353
|
+
unsubscribe: () => {},
|
|
354
|
+
lastSeq: 0,
|
|
355
|
+
estimatedTokens: 0,
|
|
356
|
+
canceled: false,
|
|
357
|
+
finalized: false,
|
|
358
|
+
deliveries: Promise.resolve(),
|
|
359
|
+
runningMs: 0,
|
|
360
|
+
legStartedAt: Date.now(),
|
|
361
|
+
parkedMs: 0
|
|
362
|
+
};
|
|
363
|
+
this.#running.set(id, job);
|
|
364
|
+
const updated = await this.#adapter.update(id, {
|
|
365
|
+
startedAt: Date.now(),
|
|
366
|
+
sessionId: runner.id
|
|
367
|
+
});
|
|
368
|
+
if (updated) job.record = updated;
|
|
369
|
+
this.#emit(job.record, {
|
|
370
|
+
type: "job_started",
|
|
371
|
+
job: job.record.info,
|
|
372
|
+
ts: Date.now()
|
|
373
|
+
});
|
|
374
|
+
const durationLimit = this.#effectiveDurationLimit(record.request);
|
|
375
|
+
if (durationLimit !== void 0) {
|
|
376
|
+
job.durationTimer = setTimeout(() => this.#kill(job, `job exceeded max duration (${durationLimit}ms)`), durationLimit);
|
|
377
|
+
job.durationTimer.unref?.();
|
|
378
|
+
}
|
|
379
|
+
job.unsubscribe = runner.subscribe((event) => void this.#handleEvent(job, event));
|
|
380
|
+
}
|
|
381
|
+
/** Kill a run: interrupt it and, if the CLI never yields a result (stuck process),
|
|
382
|
+
* force-finalize after the grace period so the job can't hang forever. */
|
|
383
|
+
#kill(job, reason) {
|
|
384
|
+
if (job.finalized || job.killReason) return;
|
|
385
|
+
job.killReason = reason;
|
|
386
|
+
if (job.parkedAt !== void 0) {
|
|
387
|
+
this.#finalize(job, {
|
|
388
|
+
usage: {
|
|
389
|
+
tokens: job.estimatedTokens,
|
|
390
|
+
totalCostUsd: 0,
|
|
391
|
+
numTurns: 0
|
|
392
|
+
},
|
|
393
|
+
status: job.canceled ? "canceled" : "failed",
|
|
394
|
+
error: reason
|
|
395
|
+
});
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
job.runner.interrupt().catch(() => {});
|
|
399
|
+
job.forceTimer = setTimeout(() => {
|
|
400
|
+
this.#finalize(job, {
|
|
401
|
+
usage: {
|
|
402
|
+
tokens: job.estimatedTokens,
|
|
403
|
+
totalCostUsd: 0,
|
|
404
|
+
numTurns: 0
|
|
405
|
+
},
|
|
406
|
+
status: job.canceled ? "canceled" : "failed",
|
|
407
|
+
error: reason
|
|
408
|
+
});
|
|
409
|
+
}, this.#options.killGraceMs ?? 5e3);
|
|
410
|
+
job.forceTimer.unref?.();
|
|
411
|
+
}
|
|
412
|
+
async #handleEvent(job, event) {
|
|
413
|
+
if (job.finalized) return;
|
|
414
|
+
job.lastSeq = Math.max(job.lastSeq, event.seq);
|
|
415
|
+
switch (event.type) {
|
|
416
|
+
case "system_init":
|
|
417
|
+
await this.#adapter.update(job.record.info.id, { sdkSessionId: event.sdkSessionId });
|
|
418
|
+
return;
|
|
419
|
+
case "assistant_message": {
|
|
420
|
+
if (event.replay) return;
|
|
421
|
+
job.estimatedTokens += sumUsage(event.message.usage);
|
|
422
|
+
const limit = this.#effectiveTokenLimit(job.record.request);
|
|
423
|
+
if (limit !== void 0 && job.estimatedTokens > limit) this.#kill(job, `session token limit exceeded (${job.estimatedTokens} > ${limit})`);
|
|
424
|
+
const progress = textPreview(event.message);
|
|
425
|
+
if (progress) this.#progress(job, progress);
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
case "permission_requested":
|
|
429
|
+
this.#progress(job, {
|
|
430
|
+
kind: "permission_requested",
|
|
431
|
+
preview: event.request.title ?? event.request.toolName,
|
|
432
|
+
request: event.request
|
|
433
|
+
});
|
|
434
|
+
return;
|
|
435
|
+
case "permission_resolved":
|
|
436
|
+
this.#progress(job, {
|
|
437
|
+
kind: "permission_resolved",
|
|
438
|
+
preview: event.behavior
|
|
439
|
+
});
|
|
440
|
+
return;
|
|
441
|
+
case "turn_result": {
|
|
442
|
+
const tokens = sumUsage(event.usage) || job.estimatedTokens;
|
|
443
|
+
await this.#finalize(job, {
|
|
444
|
+
usage: {
|
|
445
|
+
tokens,
|
|
446
|
+
totalCostUsd: event.totalCostUsd,
|
|
447
|
+
numTurns: event.numTurns
|
|
448
|
+
},
|
|
449
|
+
result: {
|
|
450
|
+
subtype: event.subtype,
|
|
451
|
+
isError: event.isError,
|
|
452
|
+
result: event.result,
|
|
453
|
+
errors: event.errors,
|
|
454
|
+
durationMs: event.durationMs
|
|
455
|
+
},
|
|
456
|
+
status: job.killReason ? job.canceled ? "canceled" : "failed" : event.isError ? "failed" : "succeeded",
|
|
457
|
+
error: job.killReason ?? (event.isError ? event.errors?.join("; ") || event.subtype : void 0)
|
|
458
|
+
});
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
case "session_error":
|
|
462
|
+
await this.#finalize(job, {
|
|
463
|
+
usage: {
|
|
464
|
+
tokens: job.estimatedTokens,
|
|
465
|
+
totalCostUsd: 0,
|
|
466
|
+
numTurns: 0
|
|
467
|
+
},
|
|
468
|
+
status: job.canceled ? "canceled" : "failed",
|
|
469
|
+
error: job.killReason ?? event.message
|
|
470
|
+
});
|
|
471
|
+
return;
|
|
472
|
+
case "session_closed":
|
|
473
|
+
await this.#finalize(job, {
|
|
474
|
+
usage: {
|
|
475
|
+
tokens: job.estimatedTokens,
|
|
476
|
+
totalCostUsd: 0,
|
|
477
|
+
numTurns: 0
|
|
478
|
+
},
|
|
479
|
+
status: job.canceled ? "canceled" : "failed",
|
|
480
|
+
error: job.killReason ?? "session closed before completing"
|
|
481
|
+
});
|
|
482
|
+
return;
|
|
483
|
+
default: return;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
#effectiveTokenLimit(request) {
|
|
487
|
+
const limits = [request.maxTokens, this.#options.sessionTokenLimit].filter((n) => typeof n === "number");
|
|
488
|
+
return limits.length > 0 ? Math.min(...limits) : void 0;
|
|
489
|
+
}
|
|
490
|
+
#effectiveDurationLimit(request) {
|
|
491
|
+
const limits = [request.maxDurationMs, this.#options.maxJobDurationMs].filter((n) => typeof n === "number");
|
|
492
|
+
return limits.length > 0 ? Math.min(...limits) : void 0;
|
|
493
|
+
}
|
|
494
|
+
/** End the current run. `patch.usage` is this attempt's usage alone — prior attempts'
|
|
495
|
+
* totals live on the stored info and are folded in here. A failed (not canceled) run
|
|
496
|
+
* with attempts left re-queues with backoff instead of completing. */
|
|
497
|
+
async #finalize(job, patch) {
|
|
498
|
+
if (job.finalized) return;
|
|
499
|
+
job.finalized = true;
|
|
500
|
+
job.unsubscribe();
|
|
501
|
+
clearTimeout(job.durationTimer);
|
|
502
|
+
clearTimeout(job.forceTimer);
|
|
503
|
+
clearTimeout(job.parkTimer);
|
|
504
|
+
this.#running.delete(job.record.info.id);
|
|
505
|
+
const wasParked = this.#parked.delete(job.record.info.id);
|
|
506
|
+
job.runner.close("server");
|
|
507
|
+
if (wasParked && job.record.info.sessionId) try {
|
|
508
|
+
Promise.resolve(this.#options.discardSession?.(job.record.info.sessionId)).catch(() => {});
|
|
509
|
+
} catch {}
|
|
510
|
+
const attemptUsage = patch.usage ?? {
|
|
511
|
+
tokens: 0,
|
|
512
|
+
totalCostUsd: 0,
|
|
513
|
+
numTurns: 0
|
|
514
|
+
};
|
|
515
|
+
if (attemptUsage.tokens > 0) await this.#adapter.addDailyTokens(dayKey(Date.now()), attemptUsage.tokens);
|
|
516
|
+
const prior = job.record.info.usage;
|
|
517
|
+
const usage = {
|
|
518
|
+
tokens: prior.tokens + attemptUsage.tokens,
|
|
519
|
+
totalCostUsd: prior.totalCostUsd + attemptUsage.totalCostUsd,
|
|
520
|
+
numTurns: prior.numTurns + attemptUsage.numTurns
|
|
521
|
+
};
|
|
522
|
+
const attempt = job.record.info.attempt ?? 1;
|
|
523
|
+
const maxAttempts = job.record.request.attempts ?? 1;
|
|
524
|
+
if (patch.status === "failed" && attempt < maxAttempts && !this.#closed) {
|
|
525
|
+
const delay = (job.record.request.retryDelayMs ?? 5e3) * 2 ** (attempt - 1);
|
|
526
|
+
const updated = await this.#adapter.update(job.record.info.id, {
|
|
527
|
+
status: "queued",
|
|
528
|
+
attempt: attempt + 1,
|
|
529
|
+
nextRunAt: Date.now() + delay,
|
|
530
|
+
error: patch.error,
|
|
531
|
+
usage,
|
|
532
|
+
sessionId: void 0,
|
|
533
|
+
sdkSessionId: void 0,
|
|
534
|
+
startedAt: void 0,
|
|
535
|
+
result: void 0
|
|
536
|
+
});
|
|
537
|
+
if (updated) {
|
|
538
|
+
job.record = updated;
|
|
539
|
+
this.#emit(updated, {
|
|
540
|
+
type: "job_retrying",
|
|
541
|
+
job: updated.info,
|
|
542
|
+
ts: Date.now()
|
|
543
|
+
}, job);
|
|
544
|
+
const timer = setTimeout(() => {
|
|
545
|
+
this.#retryTimers.delete(timer);
|
|
546
|
+
this.#pump();
|
|
547
|
+
}, delay);
|
|
548
|
+
timer.unref?.();
|
|
549
|
+
this.#retryTimers.add(timer);
|
|
550
|
+
}
|
|
551
|
+
this.#pump();
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
const updated = await this.#adapter.update(job.record.info.id, {
|
|
555
|
+
...patch,
|
|
556
|
+
usage,
|
|
557
|
+
nextRunAt: void 0,
|
|
558
|
+
finishedAt: Date.now()
|
|
559
|
+
});
|
|
560
|
+
if (updated) {
|
|
561
|
+
job.record = updated;
|
|
562
|
+
this.#emit(job.record, {
|
|
563
|
+
type: "job_completed",
|
|
564
|
+
job: updated.info,
|
|
565
|
+
ts: Date.now()
|
|
566
|
+
}, job);
|
|
567
|
+
}
|
|
568
|
+
this.#sweep();
|
|
569
|
+
this.#pump();
|
|
570
|
+
}
|
|
571
|
+
#progress(job, progress) {
|
|
572
|
+
const event = {
|
|
573
|
+
type: "job_progress",
|
|
574
|
+
job: job.record.info,
|
|
575
|
+
progress,
|
|
576
|
+
ts: Date.now()
|
|
577
|
+
};
|
|
578
|
+
if (job.record.request.webhook?.progress === "completion") {
|
|
579
|
+
try {
|
|
580
|
+
this.#options.onEvent?.(event);
|
|
581
|
+
} catch {}
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
this.#emit(job.record, event, job);
|
|
585
|
+
}
|
|
586
|
+
/** Notify the local observer and, when configured, the job's webhook (ordered per job). */
|
|
587
|
+
#emit(record, event, chainOwner, { skipWebhook = false } = {}) {
|
|
588
|
+
try {
|
|
589
|
+
this.#options.onEvent?.(event);
|
|
590
|
+
} catch {}
|
|
591
|
+
const webhook = record.request.webhook;
|
|
592
|
+
if (!webhook || skipWebhook) return;
|
|
593
|
+
const running = chainOwner ?? this.#running.get(record.info.id);
|
|
594
|
+
const deliver = () => this.#deliver(webhook.url, webhook.headers, event);
|
|
595
|
+
if (running) running.deliveries = running.deliveries.then(deliver);
|
|
596
|
+
else deliver();
|
|
597
|
+
}
|
|
598
|
+
async #deliver(url, headers, event) {
|
|
599
|
+
const fetchImpl = this.#options.fetchImpl ?? fetch;
|
|
600
|
+
const attempts = this.#options.webhookAttempts ?? 3;
|
|
601
|
+
const baseDelay = this.#options.webhookRetryDelayMs ?? 500;
|
|
602
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
603
|
+
try {
|
|
604
|
+
if ((await fetchImpl(url, {
|
|
605
|
+
method: "POST",
|
|
606
|
+
headers: {
|
|
607
|
+
"content-type": "application/json",
|
|
608
|
+
...headers
|
|
609
|
+
},
|
|
610
|
+
body: JSON.stringify(event)
|
|
611
|
+
})).ok) return;
|
|
612
|
+
} catch {}
|
|
613
|
+
if (attempt < attempts - 1) await new Promise((resolve) => setTimeout(resolve, baseDelay * 2 ** attempt));
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
};
|
|
617
|
+
//#endregion
|
|
618
|
+
export { InMemoryQueueAdapter, JobQueue };
|
|
619
|
+
|
|
620
|
+
//# sourceMappingURL=index.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.mjs","names":["#jobs","#dailyTokens","#options","#adapter","#offWork","#pump","#sweepTimer","#sweep","#closed","#emit","#bySession","#running","#parked","#kill","#recordPark","#effectiveDurationLimit","#handleEvent","#recordResume","#finalize","#retryTimers","#pumping","#start","#effectiveTokenLimit","#progress","#deliver"],"sources":["../src/adapter.ts","../src/queue.ts"],"sourcesContent":["import type { CreateJobRequest, JobInfo } from '@workerdeck/protocol'\n\n/** A job as the adapter stores it: the wire-visible info plus the original request. */\nexport type JobRecord = {\n info: JobInfo\n request: CreateJobRequest\n}\n\n/**\n * Storage + claiming contract the JobQueue runs against. The bundled implementation\n * is {@link InMemoryQueueAdapter}; redis/bullmq/pubsub adapters implement the same\n * interface. Everything is Promise-based so remote backends fit without changing the\n * queue, and `claimNext` is the one operation that must be atomic across workers\n * (two concurrent claims must never return the same job).\n */\nexport interface QueueAdapter {\n /** Persist a newly submitted job (status 'queued'). */\n add(job: JobRecord): Promise<void>\n /**\n * Atomically claim the oldest claimable queued job, transitioning it to 'running'.\n * A queued job whose `nextRunAt` is in the future (retry backoff) is not claimable\n * yet. Returns null when nothing is claimable.\n */\n claimNext(): Promise<JobRecord | null>\n get(id: string): Promise<JobRecord | null>\n /** All known jobs, oldest first. Backends may cap retention of terminal jobs. */\n list(): Promise<JobRecord[]>\n /** Merge a partial info patch into a job (a key explicitly set to undefined clears\n * that field). Returns the updated record, or null if unknown. */\n update(id: string, patch: Partial<JobInfo>): Promise<JobRecord | null>\n /** Delete terminal jobs (succeeded/failed/canceled) that finished more than\n * `olderThanMs` ago. Returns how many were removed. */\n prune(olderThanMs: number): Promise<number>\n /**\n * Add tokens to a day's global counter and return the new total. `dayKey` is a UTC\n * 'YYYY-MM-DD'; keeping the counter in the adapter makes daily budgets hold across\n * multiple workers sharing a backend.\n */\n addDailyTokens(dayKey: string, tokens: number): Promise<number>\n dailyTokens(dayKey: string): Promise<number>\n /**\n * Optional: notify the queue that work may be available (a job added by another\n * producer on a shared backend). The bundled queue also pumps after its own\n * submits/completions, so purely local adapters can omit this.\n */\n onWork?(listener: () => void): () => void\n}\n\n/** Reference adapter: single-process, no persistence. Jobs and daily counters are lost\n * on restart — production deployments should back the queue with a shared store. */\nexport class InMemoryQueueAdapter implements QueueAdapter {\n #jobs = new Map<string, JobRecord>()\n #dailyTokens = new Map<string, number>()\n\n add(job: JobRecord): Promise<void> {\n this.#jobs.set(job.info.id, job)\n return Promise.resolve()\n }\n\n claimNext(): Promise<JobRecord | null> {\n const now = Date.now()\n for (const job of this.#jobs.values()) {\n if (\n job.info.status === 'queued' &&\n (job.info.nextRunAt === undefined || job.info.nextRunAt <= now)\n ) {\n job.info = { ...job.info, status: 'running' }\n return Promise.resolve(job)\n }\n }\n return Promise.resolve(null)\n }\n\n get(id: string): Promise<JobRecord | null> {\n return Promise.resolve(this.#jobs.get(id) ?? null)\n }\n\n list(): Promise<JobRecord[]> {\n return Promise.resolve([...this.#jobs.values()])\n }\n\n update(id: string, patch: Partial<JobInfo>): Promise<JobRecord | null> {\n const job = this.#jobs.get(id)\n if (!job) return Promise.resolve(null)\n job.info = { ...job.info, ...patch }\n return Promise.resolve(job)\n }\n\n prune(olderThanMs: number): Promise<number> {\n const cutoff = Date.now() - olderThanMs\n let removed = 0\n for (const [id, job] of this.#jobs) {\n const { status, finishedAt } = job.info\n const terminal = status === 'succeeded' || status === 'failed' || status === 'canceled'\n if (terminal && (finishedAt ?? 0) <= cutoff) {\n this.#jobs.delete(id)\n removed++\n }\n }\n return Promise.resolve(removed)\n }\n\n addDailyTokens(dayKey: string, tokens: number): Promise<number> {\n const next = (this.#dailyTokens.get(dayKey) ?? 0) + tokens\n this.#dailyTokens.set(dayKey, next)\n return Promise.resolve(next)\n }\n\n dailyTokens(dayKey: string): Promise<number> {\n return Promise.resolve(this.#dailyTokens.get(dayKey) ?? 0)\n }\n}\n","import { randomUUID } from 'node:crypto'\nimport type { Runner, SessionRunnerConfig } from '@workerdeck/core'\nimport type {\n ApiMessage,\n CreateJobRequest,\n CreateSessionRequest,\n JobEvent,\n JobInfo,\n JobProgress,\n QueueStats,\n SessionEvent,\n} from '@workerdeck/protocol'\nimport { InMemoryQueueAdapter, type JobRecord, type QueueAdapter } from './adapter.ts'\n\nexport type JobQueueOptions = {\n /** Turn a session config into a live runner — typically the server registry's create(),\n * so job sessions are ordinary sessions clients can attach to and watch. May be\n * async: engines whose assembly awaits (a provider session's MCP connect) resolve\n * here, and a rejection fails the job like any other start error. */\n createRunner: (config: SessionRunnerConfig) => Runner | Promise<Runner>\n /** Storage/claiming backend. Defaults to the in-memory adapter (single process). */\n adapter?: QueueAdapter\n /** Concurrent job sessions. Default 1. */\n maxConcurrency?: number\n /** Token cap per job session; exceeding it interrupts the run and fails the job. */\n sessionTokenLimit?: number\n /** Global token budget per UTC day; when exhausted, queued jobs are held until the\n * day rolls over (running jobs finish and are accounted). */\n dailyTokenLimit?: number\n /** Wall-clock cap per job run; exceeding it interrupts the run and fails the job.\n * The watchdog for stuck CLIs — without it, a run that never yields a result keeps\n * its job (and concurrency slot) forever. Time spent parked on a deferred\n * execution does not count against it: the run isn't stuck, it's waiting. */\n maxJobDurationMs?: number\n /** Cap on time parked on a deferred execution, across all parks of one run.\n * Exceeding it fails the job (the execution's own watchdog, when the backend set\n * one, usually fires first and lets the agent adapt instead). Unset = unbounded. */\n maxParkedDurationMs?: number\n /** How long a killed run (token/duration limit) may wind down after interrupt()\n * before the queue force-finalizes it and closes the session. Default 5000. */\n killGraceMs?: number\n /** Expire terminal jobs: prune those finished more than `maxAgeMs` ago, sweeping\n * every `sweepIntervalMs` (default min(maxAgeMs, 60s)) and after each completion.\n * Unset = keep forever (the in-memory adapter then grows unboundedly). */\n retention?: { maxAgeMs: number; sweepIntervalMs?: number }\n /** Patch job session configs (inject queryFn, env, tool policy) before they run. */\n buildRunnerConfig?: (req: CreateSessionRequest) => SessionRunnerConfig\n /**\n * Drop a parked session's persisted state: the run ended (cancel, kill, retry)\n * while parked, so nothing will ever rehydrate it. The host that parks sessions\n * — {@link JobQueue.onSessionParking}'s caller — wires this to its session store.\n */\n discardSession?: (sessionId: string) => void | Promise<void>\n /** Webhook transport. Defaults to global fetch. */\n fetchImpl?: typeof fetch\n /** Webhook delivery attempts per event (exponential backoff). Default 3. */\n webhookAttempts?: number\n /** Initial backoff between webhook attempts. Default 500ms. */\n webhookRetryDelayMs?: number\n /** Local observer invoked for every job event (in addition to any webhook). */\n onEvent?: (event: JobEvent) => void\n}\n\ntype RunningJob = {\n record: JobRecord\n /** Rebuilt on every resume: a rehydrated session is a NEW runner object under\n * the same session id, so nothing may hold the old reference. */\n runner: Runner\n unsubscribe: () => void\n /** Highest event seq seen. A resumed run re-subscribes from here, so the parked\n * session's replayed log doesn't re-deliver a turn the queue already acted on. */\n lastSeq: number\n /** Mid-run token estimate from assistant-message usage (enforcement + progress). */\n estimatedTokens: number\n /** Set when the queue killed the run (limits, cancel) — decides the terminal status. */\n killReason?: string\n canceled: boolean\n finalized: boolean\n /** Per-job webhook chain so deliveries stay ordered. */\n deliveries: Promise<void>\n /** Watchdog: fires killReason when the run exceeds its wall-clock cap. */\n durationTimer?: ReturnType<typeof setTimeout>\n /** Backstop after a kill: force-finalizes if interrupt() never yields a result. */\n forceTimer?: ReturnType<typeof setTimeout>\n /** Wall-clock actually spent running (parked stretches excluded), plus when the\n * current running leg started. Together they bound the duration watchdog across\n * any number of parks. */\n runningMs: number\n legStartedAt: number\n /** Set while parked: when it parked and what it waits on. */\n parkedAt?: number\n parkedExecutionId?: string\n /** Watchdog on total parked time (maxParkedDurationMs). */\n parkTimer?: ReturnType<typeof setTimeout>\n parkedMs: number\n}\n\nconst dayKey = (epochMs: number): string => new Date(epochMs).toISOString().slice(0, 10)\n\nconst sumUsage = (usage: unknown): number => {\n if (typeof usage !== 'object' || usage === null) return 0\n const u = usage as Record<string, unknown>\n return (\n (typeof u.input_tokens === 'number' ? u.input_tokens : 0) +\n (typeof u.output_tokens === 'number' ? u.output_tokens : 0) +\n (typeof u.cache_creation_input_tokens === 'number' ? u.cache_creation_input_tokens : 0) +\n (typeof u.cache_read_input_tokens === 'number' ? u.cache_read_input_tokens : 0)\n )\n}\n\nconst textPreview = (message: ApiMessage, max = 140): JobProgress | null => {\n const blocks = typeof message.content === 'string'\n ? [{ type: 'text', text: message.content }]\n : message.content\n for (const block of blocks) {\n if (block.type === 'tool_use') {\n return { kind: 'tool_use', preview: (block as { name?: string }).name }\n }\n if (block.type === 'text') {\n const text = (block as { text?: string }).text ?? ''\n if (text.trim()) {\n return {\n kind: 'assistant_text',\n preview: text.length > max ? text.slice(0, max - 1) + '…' : text,\n }\n }\n }\n }\n return null\n}\n\n/**\n * One-shot job execution over the session runner: submitted jobs run `session.prompt`\n * unattended, bounded by `maxConcurrency` and token budgets, and report progress and\n * completion through webhooks (plus `onEvent` locally). Job state lives in the\n * {@link QueueAdapter}; this class owns scheduling and the live runs.\n */\nexport class JobQueue {\n #options: JobQueueOptions\n #adapter: QueueAdapter\n #running = new Map<string, RunningJob>()\n /** Runs waiting on a deferred execution: alive, but holding no concurrency slot\n * and no live runner. Keyed by job id like `#running`. */\n #parked = new Map<string, RunningJob>()\n #pumping = false\n #closed = false\n #offWork: (() => void) | undefined\n #sweepTimer: ReturnType<typeof setInterval> | undefined\n /** Pending retry-backoff wakeups, cleared on close(). */\n #retryTimers = new Set<ReturnType<typeof setTimeout>>()\n\n constructor(options: JobQueueOptions) {\n this.#options = options\n this.#adapter = options.adapter ?? new InMemoryQueueAdapter()\n this.#offWork = this.#adapter.onWork?.(() => void this.#pump())\n const retention = options.retention\n if (retention) {\n const interval = retention.sweepIntervalMs ?? Math.min(retention.maxAgeMs, 60_000)\n this.#sweepTimer = setInterval(() => this.#sweep(), interval)\n this.#sweepTimer.unref?.()\n }\n }\n\n async submit(request: CreateJobRequest): Promise<JobInfo> {\n if (this.#closed) throw new Error('queue is closed')\n if (!request.session?.prompt?.trim()) throw new Error('session.prompt is required')\n if (!request.session.cwd) throw new Error('session.cwd is required')\n if (request.session.resume || request.session.forkSession) {\n throw new Error('resume/forkSession are not supported for queued jobs')\n }\n const attempts = request.attempts ?? 1\n if (!Number.isInteger(attempts) || attempts < 1) {\n throw new Error('attempts must be a positive integer')\n }\n if (request.retryDelayMs !== undefined && !(request.retryDelayMs >= 0)) {\n throw new Error('retryDelayMs must be >= 0')\n }\n const info: JobInfo = {\n id: randomUUID(),\n status: 'queued',\n cwd: request.session.cwd,\n profile: request.session.profile,\n prompt: request.session.prompt,\n createdAt: Date.now(),\n attempt: 1,\n maxAttempts: attempts,\n usage: { tokens: 0, totalCostUsd: 0, numTurns: 0 },\n meta: request.meta,\n }\n const record: JobRecord = { info, request }\n await this.#adapter.add(record)\n this.#emit(record, { type: 'job_submitted', job: info, ts: Date.now() }, undefined, {\n skipWebhook: true,\n })\n void this.#pump()\n return info\n }\n\n async get(id: string): Promise<JobInfo | null> {\n return (await this.#adapter.get(id))?.info ?? null\n }\n\n async list(): Promise<JobInfo[]> {\n return (await this.#adapter.list()).map((j) => j.info)\n }\n\n /**\n * A run's session is about to be parked on a deferred execution: the host has\n * snapshotted it and is tearing the live runner down. The queue drops its\n * subscription to the doomed runner, frees the concurrency slot, and stops the\n * duration clock.\n *\n * Returns false when the queue refuses the park — the run is already finalizing\n * or has been killed, so the host must leave the session alone. A session that\n * belongs to no job accepts trivially (there is nothing to account for).\n */\n onSessionParking(sessionId: string, executionId: string): boolean {\n const job = this.#bySession(this.#running, sessionId)\n if (!job) return true\n if (job.finalized || job.killReason) return false\n const now = Date.now()\n job.unsubscribe()\n job.runningMs += now - job.legStartedAt\n clearTimeout(job.durationTimer)\n job.durationTimer = undefined\n job.parkedAt = now\n job.parkedExecutionId = executionId\n this.#running.delete(job.record.info.id)\n this.#parked.set(job.record.info.id, job)\n const parkedLimit = this.#options.maxParkedDurationMs\n if (parkedLimit !== undefined) {\n job.parkTimer = setTimeout(\n () => this.#kill(job, `job exceeded max parked duration (${parkedLimit}ms)`),\n Math.max(0, parkedLimit - job.parkedMs),\n )\n job.parkTimer.unref?.()\n }\n void this.#recordPark(job, executionId)\n return true\n }\n\n async #recordPark(job: RunningJob, executionId: string): Promise<void> {\n const updated = await this.#adapter.update(job.record.info.id, {\n status: 'parked',\n parkedAt: job.parkedAt,\n parkedExecutionId: executionId,\n })\n if (updated) job.record = updated\n this.#emit(\n job.record,\n { type: 'job_parked', job: job.record.info, executionId, ts: Date.now() },\n job,\n )\n // The slot is free now — let a queued job take it.\n void this.#pump()\n }\n\n /**\n * The parked session was rehydrated (same session id, new runner object) because\n * its execution's result arrived. Re-subscribe and restart the clock with the\n * budget the run had left.\n *\n * A resume takes its slot back immediately, so a burst of resumes can transiently\n * exceed `maxConcurrency` — the alternative would be holding a result the agent\n * loop has already been handed.\n */\n onSessionResumed(sessionId: string, runner: Runner): void {\n const job = this.#bySession(this.#parked, sessionId)\n if (!job || job.finalized) return\n const now = Date.now()\n const executionId = job.parkedExecutionId ?? ''\n clearTimeout(job.parkTimer)\n job.parkTimer = undefined\n job.parkedMs += now - (job.parkedAt ?? now)\n job.parkedAt = undefined\n job.parkedExecutionId = undefined\n job.legStartedAt = now\n job.runner = runner\n this.#parked.delete(job.record.info.id)\n this.#running.set(job.record.info.id, job)\n const durationLimit = this.#effectiveDurationLimit(job.record.request)\n if (durationLimit !== undefined) {\n job.durationTimer = setTimeout(\n () => this.#kill(job, `job exceeded max duration (${durationLimit}ms)`),\n Math.max(0, durationLimit - job.runningMs),\n )\n job.durationTimer.unref?.()\n }\n // From lastSeq, not 0: the rehydrated runner replays the whole persisted log,\n // and re-handling those events would double-count tokens and could re-finalize\n // the job on an old turn_result.\n job.unsubscribe = runner.subscribe((event) => void this.#handleEvent(job, event), job.lastSeq)\n void this.#recordResume(job, executionId)\n }\n\n async #recordResume(job: RunningJob, executionId: string): Promise<void> {\n const updated = await this.#adapter.update(job.record.info.id, {\n status: 'running',\n parkedAt: undefined,\n parkedExecutionId: undefined,\n })\n if (updated) job.record = updated\n this.#emit(\n job.record,\n { type: 'job_resumed', job: job.record.info, executionId, ts: Date.now() },\n job,\n )\n }\n\n #bySession(jobs: Map<string, RunningJob>, sessionId: string): RunningJob | undefined {\n for (const job of jobs.values()) {\n if (job.record.info.sessionId === sessionId) return job\n }\n return undefined\n }\n\n /** Cancel a queued, running, or parked job. Returns the job, or null if unknown. */\n async cancel(id: string): Promise<JobInfo | null> {\n const record = await this.#adapter.get(id)\n if (!record) return null\n const running = this.#running.get(id) ?? this.#parked.get(id)\n if (running) {\n running.canceled = true\n running.killReason = 'canceled'\n await this.#finalize(running, {\n usage: { tokens: running.estimatedTokens, totalCostUsd: 0, numTurns: 0 },\n status: 'canceled',\n error: 'canceled',\n })\n return running.record.info\n }\n if (record.info.status !== 'queued') return record.info\n const updated = await this.#adapter.update(id, {\n status: 'canceled',\n finishedAt: Date.now(),\n error: 'canceled',\n })\n if (updated) this.#emit(updated, { type: 'job_completed', job: updated.info, ts: Date.now() })\n return updated?.info ?? null\n }\n\n async stats(): Promise<QueueStats> {\n const jobs = await this.#adapter.list()\n const dailyTokensUsed = await this.#adapter.dailyTokens(dayKey(Date.now()))\n const dailyTokenLimit = this.#options.dailyTokenLimit\n return {\n maxConcurrency: this.#options.maxConcurrency ?? 1,\n running: this.#running.size,\n parked: this.#parked.size,\n queued: jobs.filter((j) => j.info.status === 'queued').length,\n sessionTokenLimit: this.#options.sessionTokenLimit,\n dailyTokenLimit,\n dailyTokensUsed,\n paused: dailyTokenLimit !== undefined && dailyTokensUsed >= dailyTokenLimit,\n }\n }\n\n /** Stop scheduling new jobs. Running jobs keep finalizing (e.g. when the host closes\n * their sessions); job state stays in the adapter. */\n close(): void {\n this.#closed = true\n this.#offWork?.()\n clearInterval(this.#sweepTimer)\n for (const timer of this.#retryTimers) clearTimeout(timer)\n this.#retryTimers.clear()\n }\n\n #sweep(): void {\n const retention = this.#options.retention\n if (!retention) return\n this.#adapter.prune(retention.maxAgeMs).catch(() => {\n // sweep failures must not break the queue; the next sweep retries\n })\n }\n\n async #pump(): Promise<void> {\n if (this.#pumping || this.#closed) return\n this.#pumping = true\n try {\n const maxConcurrency = this.#options.maxConcurrency ?? 1\n while (this.#running.size < maxConcurrency) {\n const limit = this.#options.dailyTokenLimit\n if (limit !== undefined && (await this.#adapter.dailyTokens(dayKey(Date.now()))) >= limit) {\n return\n }\n const record = await this.#adapter.claimNext()\n if (!record) return\n await this.#start(record)\n }\n } finally {\n this.#pumping = false\n }\n }\n\n async #start(record: JobRecord): Promise<void> {\n const id = record.info.id\n const build = this.#options.buildRunnerConfig ?? ((req: CreateSessionRequest) => req)\n let runner: Runner\n try {\n runner = await this.#options.createRunner(build(record.request.session))\n } catch (error) {\n const failed = await this.#adapter.update(id, {\n status: 'failed',\n finishedAt: Date.now(),\n error: error instanceof Error ? error.message : String(error),\n })\n if (failed) this.#emit(failed, { type: 'job_completed', job: failed.info, ts: Date.now() })\n return\n }\n const job: RunningJob = {\n record,\n runner,\n unsubscribe: () => {},\n lastSeq: 0,\n estimatedTokens: 0,\n canceled: false,\n finalized: false,\n deliveries: Promise.resolve(),\n runningMs: 0,\n legStartedAt: Date.now(),\n parkedMs: 0,\n }\n this.#running.set(id, job)\n const updated = await this.#adapter.update(id, {\n startedAt: Date.now(),\n sessionId: runner.id,\n })\n if (updated) job.record = updated\n this.#emit(job.record, { type: 'job_started', job: job.record.info, ts: Date.now() })\n const durationLimit = this.#effectiveDurationLimit(record.request)\n if (durationLimit !== undefined) {\n job.durationTimer = setTimeout(\n () => this.#kill(job, `job exceeded max duration (${durationLimit}ms)`),\n durationLimit,\n )\n job.durationTimer.unref?.()\n }\n job.unsubscribe = runner.subscribe((event) => void this.#handleEvent(job, event))\n }\n\n /** Kill a run: interrupt it and, if the CLI never yields a result (stuck process),\n * force-finalize after the grace period so the job can't hang forever. */\n #kill(job: RunningJob, reason: string): void {\n if (job.finalized || job.killReason) return\n job.killReason = reason\n if (job.parkedAt !== undefined) {\n // Parked: there is no live runner to interrupt and no result coming, so the\n // grace period would just be dead time.\n void this.#finalize(job, {\n usage: { tokens: job.estimatedTokens, totalCostUsd: 0, numTurns: 0 },\n status: job.canceled ? 'canceled' : 'failed',\n error: reason,\n })\n return\n }\n void job.runner.interrupt().catch(() => {})\n job.forceTimer = setTimeout(() => {\n void this.#finalize(job, {\n usage: { tokens: job.estimatedTokens, totalCostUsd: 0, numTurns: 0 },\n status: job.canceled ? 'canceled' : 'failed',\n error: reason,\n })\n }, this.#options.killGraceMs ?? 5000)\n job.forceTimer.unref?.()\n }\n\n async #handleEvent(job: RunningJob, event: SessionEvent): Promise<void> {\n if (job.finalized) return\n job.lastSeq = Math.max(job.lastSeq, event.seq)\n switch (event.type) {\n case 'system_init':\n await this.#adapter.update(job.record.info.id, { sdkSessionId: event.sdkSessionId })\n return\n case 'assistant_message': {\n if (event.replay) return\n job.estimatedTokens += sumUsage(event.message.usage)\n const limit = this.#effectiveTokenLimit(job.record.request)\n if (limit !== undefined && job.estimatedTokens > limit) {\n this.#kill(job, `session token limit exceeded (${job.estimatedTokens} > ${limit})`)\n }\n const progress = textPreview(event.message)\n if (progress) this.#progress(job, progress)\n return\n }\n case 'permission_requested':\n // The full request rides along so webhook consumers can answer it over REST\n // (questions, approvals) instead of only seeing a preview string.\n this.#progress(job, {\n kind: 'permission_requested',\n preview: event.request.title ?? event.request.toolName,\n request: event.request,\n })\n return\n case 'permission_resolved':\n this.#progress(job, { kind: 'permission_resolved', preview: event.behavior })\n return\n case 'turn_result': {\n // One job = one unattended run: the first result is the outcome.\n const tokens = sumUsage(event.usage) || job.estimatedTokens\n await this.#finalize(job, {\n usage: {\n tokens,\n totalCostUsd: event.totalCostUsd,\n numTurns: event.numTurns,\n },\n result: {\n subtype: event.subtype,\n isError: event.isError,\n result: event.result,\n errors: event.errors,\n durationMs: event.durationMs,\n },\n status: job.killReason\n ? (job.canceled ? 'canceled' : 'failed')\n : event.isError\n ? 'failed'\n : 'succeeded',\n error: job.killReason ?? (event.isError ? (event.errors?.join('; ') || event.subtype) : undefined),\n })\n return\n }\n case 'session_error':\n await this.#finalize(job, {\n usage: { tokens: job.estimatedTokens, totalCostUsd: 0, numTurns: 0 },\n status: job.canceled ? 'canceled' : 'failed',\n error: job.killReason ?? event.message,\n })\n return\n case 'session_closed':\n await this.#finalize(job, {\n usage: { tokens: job.estimatedTokens, totalCostUsd: 0, numTurns: 0 },\n status: job.canceled ? 'canceled' : 'failed',\n error: job.killReason ?? 'session closed before completing',\n })\n return\n default:\n return\n }\n }\n\n #effectiveTokenLimit(request: CreateJobRequest): number | undefined {\n const limits = [request.maxTokens, this.#options.sessionTokenLimit].filter(\n (n): n is number => typeof n === 'number',\n )\n return limits.length > 0 ? Math.min(...limits) : undefined\n }\n\n #effectiveDurationLimit(request: CreateJobRequest): number | undefined {\n const limits = [request.maxDurationMs, this.#options.maxJobDurationMs].filter(\n (n): n is number => typeof n === 'number',\n )\n return limits.length > 0 ? Math.min(...limits) : undefined\n }\n\n /** End the current run. `patch.usage` is this attempt's usage alone — prior attempts'\n * totals live on the stored info and are folded in here. A failed (not canceled) run\n * with attempts left re-queues with backoff instead of completing. */\n async #finalize(job: RunningJob, patch: Partial<JobInfo>): Promise<void> {\n if (job.finalized) return\n job.finalized = true\n job.unsubscribe()\n clearTimeout(job.durationTimer)\n clearTimeout(job.forceTimer)\n clearTimeout(job.parkTimer)\n this.#running.delete(job.record.info.id)\n const wasParked = this.#parked.delete(job.record.info.id)\n job.runner.close('server')\n if (wasParked && job.record.info.sessionId) {\n // The live runner is already gone; what survives the run is the persisted\n // snapshot, and nothing will ever rehydrate it now.\n try {\n void Promise.resolve(this.#options.discardSession?.(job.record.info.sessionId)).catch(\n () => {},\n )\n } catch {\n // discard failures must not break finalization\n }\n }\n const attemptUsage = patch.usage ?? { tokens: 0, totalCostUsd: 0, numTurns: 0 }\n if (attemptUsage.tokens > 0) {\n await this.#adapter.addDailyTokens(dayKey(Date.now()), attemptUsage.tokens)\n }\n const prior = job.record.info.usage\n const usage = {\n tokens: prior.tokens + attemptUsage.tokens,\n totalCostUsd: prior.totalCostUsd + attemptUsage.totalCostUsd,\n numTurns: prior.numTurns + attemptUsage.numTurns,\n }\n const attempt = job.record.info.attempt ?? 1\n const maxAttempts = job.record.request.attempts ?? 1\n if (patch.status === 'failed' && attempt < maxAttempts && !this.#closed) {\n const baseDelay = job.record.request.retryDelayMs ?? 5000\n const delay = baseDelay * 2 ** (attempt - 1)\n const updated = await this.#adapter.update(job.record.info.id, {\n status: 'queued',\n attempt: attempt + 1,\n nextRunAt: Date.now() + delay,\n error: patch.error,\n usage,\n sessionId: undefined,\n sdkSessionId: undefined,\n startedAt: undefined,\n result: undefined,\n })\n if (updated) {\n job.record = updated\n this.#emit(updated, { type: 'job_retrying', job: updated.info, ts: Date.now() }, job)\n const timer = setTimeout(() => {\n this.#retryTimers.delete(timer)\n void this.#pump()\n }, delay)\n timer.unref?.()\n this.#retryTimers.add(timer)\n }\n void this.#pump()\n return\n }\n const updated = await this.#adapter.update(job.record.info.id, {\n ...patch,\n usage,\n nextRunAt: undefined,\n finishedAt: Date.now(),\n })\n if (updated) {\n job.record = updated\n // Pass the job so the completion webhook stays ordered behind its progress\n // deliveries (the running-map entry is already gone).\n this.#emit(job.record, { type: 'job_completed', job: updated.info, ts: Date.now() }, job)\n }\n this.#sweep()\n void this.#pump()\n }\n\n #progress(job: RunningJob, progress: JobProgress): void {\n const event: JobEvent = { type: 'job_progress', job: job.record.info, progress, ts: Date.now() }\n // 'completion' granularity: local observers still see progress; the webhook doesn't.\n if (job.record.request.webhook?.progress === 'completion') {\n try {\n this.#options.onEvent?.(event)\n } catch {\n // observer errors must not break the queue\n }\n return\n }\n this.#emit(job.record, event, job)\n }\n\n /** Notify the local observer and, when configured, the job's webhook (ordered per job). */\n #emit(\n record: JobRecord,\n event: JobEvent,\n chainOwner?: RunningJob,\n { skipWebhook = false }: { skipWebhook?: boolean } = {},\n ): void {\n try {\n this.#options.onEvent?.(event)\n } catch {\n // observer errors must not break the queue\n }\n const webhook = record.request.webhook\n if (!webhook || skipWebhook) return\n const running = chainOwner ?? this.#running.get(record.info.id)\n const deliver = () => this.#deliver(webhook.url, webhook.headers, event)\n if (running) running.deliveries = running.deliveries.then(deliver)\n else void deliver()\n }\n\n async #deliver(\n url: string,\n headers: Record<string, string> | undefined,\n event: JobEvent,\n ): Promise<void> {\n const fetchImpl = this.#options.fetchImpl ?? fetch\n const attempts = this.#options.webhookAttempts ?? 3\n const baseDelay = this.#options.webhookRetryDelayMs ?? 500\n for (let attempt = 0; attempt < attempts; attempt++) {\n try {\n const res = await fetchImpl(url, {\n method: 'POST',\n headers: { 'content-type': 'application/json', ...headers },\n body: JSON.stringify(event),\n })\n if (res.ok) return\n } catch {\n // network error — retry below\n }\n if (attempt < attempts - 1) {\n await new Promise((resolve) => setTimeout(resolve, baseDelay * 2 ** attempt))\n }\n }\n // Deliveries are best-effort; clients can always poll GET /jobs/:id.\n }\n}\n"],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@workerdeck/queue",
|
|
3
|
+
"version": "0.6.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Job queue over the WorkerDeck session runner: remote services schedule one-shot runs; the queue executes them as sessions with bounded concurrency and token budgets, delivering progress and completion via webhooks. Pluggable adapter interface (in-memory bundled; redis/bullmq/pubsub adapters can implement the same contract).",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"main": "./build/index.mjs",
|
|
8
|
+
"types": "./build/index.d.mts",
|
|
9
|
+
"files": [
|
|
10
|
+
"build"
|
|
11
|
+
],
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"@workerdeck/source": "./src/index.ts",
|
|
15
|
+
"types": "./build/index.d.mts",
|
|
16
|
+
"default": "./build/index.mjs"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@workerdeck/protocol": "0.6.0",
|
|
21
|
+
"@workerdeck/core": "0.6.0"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@anthropic-ai/claude-agent-sdk": "^0.3.217",
|
|
25
|
+
"@types/node": "^22.10.0",
|
|
26
|
+
"rimraf": "^6.1.3",
|
|
27
|
+
"tsdown": "^0.21.10",
|
|
28
|
+
"vitest": "^3.2.0"
|
|
29
|
+
},
|
|
30
|
+
"author": "Tobias Strebitzer",
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "git+https://github.com/workerdeck/workerdeck.git",
|
|
34
|
+
"directory": "packages/queue"
|
|
35
|
+
},
|
|
36
|
+
"homepage": "https://workerdeck.github.io/workerdeck/",
|
|
37
|
+
"bugs": "https://github.com/workerdeck/workerdeck/issues",
|
|
38
|
+
"keywords": [
|
|
39
|
+
"claude",
|
|
40
|
+
"claude-code",
|
|
41
|
+
"anthropic",
|
|
42
|
+
"agent",
|
|
43
|
+
"queue",
|
|
44
|
+
"jobs",
|
|
45
|
+
"webhooks"
|
|
46
|
+
],
|
|
47
|
+
"publishConfig": {
|
|
48
|
+
"access": "public"
|
|
49
|
+
},
|
|
50
|
+
"scripts": {
|
|
51
|
+
"clean": "rimraf build",
|
|
52
|
+
"build": "tsdown",
|
|
53
|
+
"typecheck": "tsgo -p tsconfig.json",
|
|
54
|
+
"test": "vitest run"
|
|
55
|
+
}
|
|
56
|
+
}
|