@stackstackstack/dsh-jobs-local 0.1.5

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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 DeepSeek
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.
@@ -0,0 +1,6 @@
1
+ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
2
+ # side as of the last confirmed-consistent state. Both languages carry equal authority;
3
+ # after editing either side, bring the other along and re-record with:
4
+ # pnpm run verify-translation-pairing --write packages/jobs/jobs-local/README.md
5
+ README.md: 5d75b891b66177aface47e95d719af0e6803375c
6
+ README.zh.md: 0cf08a73e4a8d4384a859136fc95499f4dc1da3c
package/README.md ADDED
@@ -0,0 +1,34 @@
1
+ # @stackstackstack/dsh-jobs-local
2
+
3
+ English | [中文](README.zh.md)
4
+
5
+ Process-local implementation of the [`@stackstackstack/dsh-jobs`](../jobs/README.md) registry contract: `LocalJobRegistry` keeps every record in memory, issues per-kind `<kind>-N` ids, and hands out fresh snapshots, never live state. Load it as a plugin and it registers as `ctx.jobs`.
6
+
7
+ ## Admission
8
+
9
+ `maxConcurrentJobsPerOwner` is a positive safe integer and defaults to `10`. Before invoking a producer, `start()` counts the exact owner's `running` and `stopping` records; all unowned jobs share one separate service bucket. Terminal history does not occupy capacity, and only producer `done` settlement releases a stopping job's place.
10
+
11
+ At capacity, `start()` fails before producer execution and id allocation with an error that names the limit and tells the model to use `job_kill`, wait for the job to finish stopping, and retry. The registry does not queue, preempt, or maintain a second mutable counter.
12
+
13
+ ## Lifecycle
14
+
15
+ Jobs belong to their owner and backend, not the producer tool fiber, so producer and controller reloads do not stop them. The first job for an owner attaches one awaited effect to the exact `Agent` scope. Owner disposal cancels that object's jobs, awaits producer quiescence, and removes their snapshots; reused agent or session ids cannot redirect an old cleanup.
16
+
17
+ Service disposal closes listeners, cancels all live jobs, awaits their records, and detaches effects from surviving owner scopes. If teardown cancellation throws, the service force-fails the record and warns that work may be orphaned instead of deadlocking. A cancellation that returns but never settles `done` remains indistinguishable from a slow stop and can stall teardown.
18
+
19
+ Settlement is first-wins: the earliest terminal outcome — producer settlement, a rejected `done` contained as `failed`, or a teardown force-failure — records once, releases waiters, and notifies listeners once with per-listener containment. Pending waits mark the job reported before listeners run so completion reporters do not duplicate notices, and a teardown cancel marks it for the same reason: nothing will read a notice addressed to an owner being destroyed. Completion is the last thing a settlement announces, after the record is committed and the visible-set change is published, because a reporter may open a model turn synchronously and every other observer must already have seen the settled record.
20
+
21
+ Controllers and listeners are layered by the scope that registered them, in the tools-registry shape: a registration files into its registering context's scope, and a read unions the global layer with the owner's scope chain. One process-wide registry therefore answers per-owner questions per owner — `start()` refuses `background jobs unavailable: no job controller serves this agent (load @stackstackstack/dsh-tool-jobs in its composition)` for an owner whose own composition attaches none, however many other compositions attach theirs, and a settlement reaches only the listeners its owner's composition registered.
22
+
23
+ ## Model Experience
24
+
25
+ Indirectly, through producer plugins and [`dsh-tool-jobs`](../tool-jobs/README.md), which render job ids, output, status, cancellation, and completion notices.
26
+
27
+ #### KV Cache effect
28
+
29
+ No direct invalidation; the named consumer owns any request-prefix changes.
30
+
31
+ ## Known Limitations and Deferred Work
32
+
33
+ - **Jobs are process-local** — records die with the harness process; durable or cross-restart execution needs a separate backend implementing the seam.
34
+ - **A silently ineffective cancel can stall teardown and hold capacity** — if `cancel` returns without settling `done`, the registry cannot distinguish it from a slow stop; the job keeps one bucket slot for the rest of the service lifetime, and only an explicit throw can be force-failed safely.
package/README.zh.md ADDED
@@ -0,0 +1,34 @@
1
+ # @stackstackstack/dsh-jobs-local
2
+
3
+ [English](README.md) | 中文
4
+
5
+ [`@stackstackstack/dsh-jobs`](../jobs/README.md) 注册表约定的进程本地实现:`LocalJobRegistry` 把每条记录保存在内存中,按 kind 签发 `<kind>-N` id,并且只交出全新快照,从不交出实时状态。作为插件加载后即注册为 `ctx.jobs`。
6
+
7
+ ## 准入
8
+
9
+ `maxConcurrentJobsPerOwner` 必须是正的安全整数,默认值为 `10`。调用生产方之前,`start()` 会统计确切 owner 的 `running` 与 `stopping` 记录;所有无 owner 任务共享另一个独立的服务级桶。终止历史不占用容量,处于 `stopping` 的任务只有在生产方 `done` 结算后才释放名额。
10
+
11
+ 达到容量时,`start()` 会在生产方执行和 id 分配前失败;错误会给出上限,并告诉模型使用 `job_kill`、等待任务完全停稳后再重试。注册表不会排队或抢占任务,也不会维护第二份可变计数。
12
+
13
+ ## 生命周期
14
+
15
+ 任务属于其所有者和后端,而不是生产方工具 fiber,因此重载生产方或控制器不会停止任务。某个所有者的第一个任务会把一个会被等待的 effect 附加到对应 `Agent` 对象的 scope 上。所有者的 dispose(资源释放)会取消该对象的任务,等待生产方完全停稳,并移除其快照;复用的 agent(智能体)id 或会话 id 无法重定向旧的清理操作。
16
+
17
+ 服务 dispose 会关闭监听器、取消所有存活任务、等待其记录完成,并从仍存活的所有者 scope 中分离 effect。如果销毁期间的取消操作抛出异常,服务会强制将记录标为失败,并警告工作可能成为孤立工作,而不会死锁。取消操作已返回但 `done` 始终未结算时,系统无法将其与缓慢停止区分开,销毁过程可能因此停滞。
18
+
19
+ 结算遵循首次结算优先原则:最早出现的终止结果(生产方结算、作为 `failed` 隔离处理的 `done` 拒绝,或销毁时的强制失败)只记录一次,随后释放等待方,再只通知监听器一次;各监听器的故障会单独隔离。挂起的等待会在监听器运行前把任务标记为已报告,因此完成报告方不会重复发出通知;销毁时的取消出于同样的理由也会标记:面向正在被销毁的所有者的通知不会有人读到。完成是一次结算最后才宣布的事情,排在记录提交与可见集变更发布之后,因为报告方可能同步开启一个模型轮次,而该结算的其他所有观察者都必须已经看到已结算的记录。
20
+
21
+ 控制器与监听器按注册方所在的 scope 分层,形状与 tools 注册表一致:一次注册归档到其注册上下文的 scope,一次读取则把全局层与所有者的 scope 链求并集。因此一个进程级注册表能逐所有者地回答逐所有者的问题——对自身组合未附加任何控制器的所有者,无论其他组合附加了多少,`start()` 都会拒绝并抛出 `background jobs unavailable: no job controller serves this agent (load @stackstackstack/dsh-tool-jobs in its composition)`;一次结算也只会抵达其所有者所属组合注册的监听器。
22
+
23
+ ## 模型体验
24
+
25
+ 通过生产方插件和 [`dsh-tool-jobs`](../tool-jobs/README.md) 间接影响;它们会呈现 job id、输出、状态、取消和完成通知。
26
+
27
+ #### KV Cache 影响
28
+
29
+ 不会直接导致 KV Cache 失效;请求前缀变更由上述消费方负责。
30
+
31
+ ## 已知限制与暂缓事项
32
+
33
+ - **任务只存在于进程本地**:记录会随 harness 进程终止而消失;持久或跨重启执行需要一个单独实现该 seam 的后端。
34
+ - **静默无效的取消可能使销毁过程停滞并持续占用容量**:如果 `cancel` 返回后始终未结算 `done`,注册表就无法将其与缓慢停止区分开;该任务会在服务剩余生命周期内持续占用一个桶名额,只有显式抛出异常才能安全地强制标为失败。
package/lib/index.js ADDED
@@ -0,0 +1,455 @@
1
+ import z from "@deepseek-ai/schemastery";
2
+ import { AnonymousEntries, ScopedLayers, scopeOf } from "@stackstackstack/dsh-scope";
3
+ import { deadline, timeoutOf } from "@stackstackstack/dsh-timeout";
4
+ import { JobId, JobRegistry } from "@stackstackstack/dsh-jobs";
5
+ //#region lib/types/index.js
6
+ /**
7
+ * Process-local provider for the background-job capability seam
8
+ * (`ctx.jobs`). It keeps every record in memory and hands out fresh
9
+ * snapshots, never live state.
10
+ *
11
+ * Registrations outlive producer and controller fibers. Agent or service
12
+ * disposal cancels live work and awaits compliant producers; a throwing
13
+ * teardown cancel force-fails only the record and reports a possible orphan.
14
+ * @module @stackstackstack/dsh-jobs-local
15
+ */
16
+ var __addDisposableResource = function(env, value, async) {
17
+ if (value !== null && value !== void 0) {
18
+ if (typeof value !== "object" && typeof value !== "function") throw new TypeError("Object expected.");
19
+ var dispose, inner;
20
+ if (async) {
21
+ if (!Symbol.asyncDispose) throw new TypeError("Symbol.asyncDispose is not defined.");
22
+ dispose = value[Symbol.asyncDispose];
23
+ }
24
+ if (dispose === void 0) {
25
+ if (!Symbol.dispose) throw new TypeError("Symbol.dispose is not defined.");
26
+ dispose = value[Symbol.dispose];
27
+ if (async) inner = dispose;
28
+ }
29
+ if (typeof dispose !== "function") throw new TypeError("Object not disposable.");
30
+ if (inner) dispose = function() {
31
+ try {
32
+ inner.call(this);
33
+ } catch (e) {
34
+ return Promise.reject(e);
35
+ }
36
+ };
37
+ env.stack.push({
38
+ value,
39
+ dispose,
40
+ async
41
+ });
42
+ } else if (async) env.stack.push({ async: true });
43
+ return value;
44
+ };
45
+ var __disposeResources = (function(SuppressedError) {
46
+ return function(env) {
47
+ function fail(e) {
48
+ env.error = env.hasError ? new SuppressedError(e, env.error, "An error was suppressed during disposal.") : e;
49
+ env.hasError = true;
50
+ }
51
+ var r, s = 0;
52
+ function next() {
53
+ while (r = env.stack.pop()) try {
54
+ if (!r.async && s === 1) return s = 0, env.stack.push(r), Promise.resolve().then(next);
55
+ if (r.dispose) {
56
+ var result = r.dispose.call(r.value);
57
+ if (r.async) return s |= 2, Promise.resolve(result).then(next, function(e) {
58
+ fail(e);
59
+ return next();
60
+ });
61
+ } else s |= 1;
62
+ } catch (e) {
63
+ fail(e);
64
+ }
65
+ if (s === 1) return env.hasError ? Promise.reject(env.error) : Promise.resolve();
66
+ if (env.hasError) throw env.error;
67
+ }
68
+ return next();
69
+ };
70
+ })(typeof SuppressedError === "function" ? SuppressedError : function(error, suppressed, message) {
71
+ var e = new Error(message);
72
+ return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
73
+ });
74
+ /** Timeout code that distinguishes a bounded wait from caller cancellation. */
75
+ const TASK_WAIT_TIMEOUT = "TASK_WAIT_TIMEOUT";
76
+ /** Default maximum number of active jobs in one exact-owner bucket. */
77
+ const DEFAULT_MAX_CONCURRENT_TASKS_PER_OWNER = 10;
78
+ /** True for the three terminal {@link JobStatus} values. */
79
+ function isTerminal(status) {
80
+ return status === "completed" || status === "killed" || status === "failed";
81
+ }
82
+ /**
83
+ * One scope's contributions: the job controllers attached from it and the
84
+ * completion listeners registered there. Both tables are anonymous because a
85
+ * contribution is identified by its own disposer, never by a name a second
86
+ * registrant could shadow.
87
+ */
88
+ var JobLayer = class {
89
+ controllers = new AnonymousEntries();
90
+ listeners = new AnonymousEntries();
91
+ changed = new AnonymousEntries();
92
+ isEmpty() {
93
+ return this.controllers.isEmpty() && this.listeners.isEmpty() && this.changed.isEmpty();
94
+ }
95
+ };
96
+ /**
97
+ * The in-memory `jobs` registry. See the Service Definition contract in
98
+ * `@stackstackstack/dsh-jobs` for the ownership, isolation, and lifecycle
99
+ * semantics this implementation honors.
100
+ */
101
+ var LocalJobRegistry = class extends JobRegistry {
102
+ static Config = z.object({ maxConcurrentJobsPerOwner: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER).default(DEFAULT_MAX_CONCURRENT_TASKS_PER_OWNER) });
103
+ /** Schemastery-defaulted active-job limit. */
104
+ maxConcurrentJobsPerOwner;
105
+ store = /* @__PURE__ */ new Map();
106
+ counters = /* @__PURE__ */ new Map();
107
+ /**
108
+ * Surfaces and listeners layered by the scope that registered them, in the
109
+ * tools-registry shape: a contribution files into its registering context's
110
+ * scope, and a read unions the global layer with the reader's scope chain.
111
+ *
112
+ * The registry is one process-wide instance serving every composition, so a
113
+ * flat table would answer a per-owner question process-wide: one preset's
114
+ * job controls would hold `start()` open for an agent whose own composition
115
+ * loads none, and one settlement would reach every preset's notice listener.
116
+ * Layers make both reads owner-relative. Nothing derives a cache from a
117
+ * layer, so change notification is a no-op.
118
+ */
119
+ layers = new ScopedLayers(() => new JobLayer(), () => {});
120
+ listenersClosed = false;
121
+ /** Owner agents with attached scope cleanup, mapped to the exact disposer. */
122
+ ownerCleanups = /* @__PURE__ */ new Map();
123
+ /** Service context used by detached settlement continuations and teardown. */
124
+ selfCtx;
125
+ constructor(ctx, config) {
126
+ super(ctx);
127
+ this.maxConcurrentJobsPerOwner = config.maxConcurrentJobsPerOwner;
128
+ this.selfCtx = ctx;
129
+ ctx.effect(() => () => this.disposeAll(), "jobs teardown");
130
+ }
131
+ start(spec) {
132
+ if (!this.servesOwner(spec.owner)) throw new Error("background jobs unavailable: no job controller serves this agent (load @stackstackstack/dsh-tool-jobs in its composition)");
133
+ if (spec.kind.length === 0) throw new Error("invalid job kind: expected a non-empty string");
134
+ if (spec.label.length === 0) throw new Error("invalid job label: expected a non-empty string");
135
+ if (spec.outputLimitBytes !== void 0 && (!Number.isSafeInteger(spec.outputLimitBytes) || spec.outputLimitBytes <= 0)) throw new Error(`invalid outputLimitBytes: expected a positive safe integer, got ${JSON.stringify(spec.outputLimitBytes)}`);
136
+ if (spec.owner !== void 0) this.ensureOwnerCleanup(spec.owner);
137
+ if (this.activeTaskCount(spec.owner) >= this.maxConcurrentJobsPerOwner) throw new Error(`background job limit reached for this owner (limit: ${this.maxConcurrentJobsPerOwner}); use job_kill to stop an unneeded job, wait for it to finish, then retry`);
138
+ const hooks = spec.run();
139
+ const count = (this.counters.get(spec.kind) ?? 0) + 1;
140
+ this.counters.set(spec.kind, count);
141
+ const id = JobId(`${spec.kind}-${count}`);
142
+ let markSettled;
143
+ const settled = new Promise((resolve) => {
144
+ markSettled = resolve;
145
+ });
146
+ const job = {
147
+ id,
148
+ kind: spec.kind,
149
+ label: spec.label,
150
+ outputLimitBytes: spec.outputLimitBytes,
151
+ owner: spec.owner,
152
+ cancel: hooks.cancel.bind(hooks),
153
+ readOutput: hooks.readOutput?.bind(hooks),
154
+ status: "running",
155
+ detail: void 0,
156
+ output: void 0,
157
+ startedAt: Date.now(),
158
+ finishedAt: void 0,
159
+ reported: false,
160
+ settled,
161
+ markSettled,
162
+ waiters: 0,
163
+ waitResolvers: /* @__PURE__ */ new Set()
164
+ };
165
+ this.store.set(id, job);
166
+ hooks.done.then((outcome) => {
167
+ this.settle(job, outcome);
168
+ }, (error) => {
169
+ this.selfCtx.logger.warn(`jobs: job ${job.id} producer done promise rejected (producer contract violation): ${String(error)}`);
170
+ this.settle(job, {
171
+ status: "failed",
172
+ detail: String(error)
173
+ });
174
+ });
175
+ this.notifyChanged(job.owner);
176
+ return id;
177
+ }
178
+ list(caller) {
179
+ const session = caller?.id;
180
+ return [...this.store.values()].filter((job) => job.owner === void 0 || job.owner.id === session).map((job) => this.snapshot(job));
181
+ }
182
+ get(id, caller) {
183
+ const job = this.expect(id);
184
+ this.assertAccess(job, caller);
185
+ return this.snapshot(job);
186
+ }
187
+ read(id, caller) {
188
+ const job = this.expect(id);
189
+ this.assertAccess(job, caller);
190
+ const text = job.readOutput !== void 0 ? job.readOutput() : isTerminal(job.status) ? job.output ?? "" : "";
191
+ if (isTerminal(job.status)) job.reported = true;
192
+ return {
193
+ text,
194
+ snapshot: this.snapshot(job)
195
+ };
196
+ }
197
+ kill(id, caller, reason) {
198
+ const job = this.expect(id);
199
+ this.assertAccess(job, caller);
200
+ if (isTerminal(job.status)) {
201
+ job.reported = true;
202
+ return "already-finished";
203
+ }
204
+ job.cancel(reason);
205
+ job.status = "stopping";
206
+ job.reported = true;
207
+ this.notifyChanged(job.owner);
208
+ return "requested";
209
+ }
210
+ async wait(id, timeoutMs, caller, signal) {
211
+ const job = this.expect(id);
212
+ this.assertAccess(job, caller);
213
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) throw new Error(`invalid wait timeout: expected a positive number of milliseconds, got ${JSON.stringify(timeoutMs)}`);
214
+ if (!isTerminal(job.status)) {
215
+ if (signal?.aborted) throw new Error("wait aborted");
216
+ job.waiters += 1;
217
+ let counted = true;
218
+ const uncount = () => {
219
+ if (!counted) return;
220
+ counted = false;
221
+ job.waiters -= 1;
222
+ };
223
+ try {
224
+ const env_1 = {
225
+ stack: [],
226
+ error: void 0,
227
+ hasError: false
228
+ };
229
+ try {
230
+ const d = __addDisposableResource(env_1, deadline(signal, timeoutMs, TASK_WAIT_TIMEOUT), false);
231
+ await new Promise((resolve, reject) => {
232
+ const onSettled = () => {
233
+ job.waitResolvers.delete(onSettled);
234
+ d.signal.removeEventListener("abort", onAbort);
235
+ resolve();
236
+ };
237
+ const onAbort = () => {
238
+ job.waitResolvers.delete(onSettled);
239
+ if (timeoutOf(d.signal, "TASK_WAIT_TIMEOUT") !== void 0) resolve();
240
+ else {
241
+ uncount();
242
+ reject(/* @__PURE__ */ new Error("wait aborted"));
243
+ }
244
+ };
245
+ job.waitResolvers.add(onSettled);
246
+ d.signal.addEventListener("abort", onAbort, { once: true });
247
+ });
248
+ } catch (e_1) {
249
+ env_1.error = e_1;
250
+ env_1.hasError = true;
251
+ } finally {
252
+ __disposeResources(env_1);
253
+ }
254
+ } finally {
255
+ uncount();
256
+ }
257
+ }
258
+ if (isTerminal(job.status)) job.reported = true;
259
+ return this.snapshot(job);
260
+ }
261
+ onJobDone(listener) {
262
+ return this.layers.effect(this.ctx, (layer) => layer.listeners.append(listener), { label: "jobs.onJobDone()" });
263
+ }
264
+ onJobsChanged(listener) {
265
+ return this.layers.effect(this.ctx, (layer) => layer.changed.append(listener), { label: "jobs.onJobsChanged()" });
266
+ }
267
+ attachController(name) {
268
+ const token = Symbol(name);
269
+ return this.layers.effect(this.ctx, (layer) => layer.controllers.append(token), { label: "jobs.attachController()" });
270
+ }
271
+ /**
272
+ * Whether an attached job controller can collect and stop work owned by
273
+ * `owner`. The global layer holds every controller attached from an unscoped
274
+ * context — a host composition's own controls — and therefore serves every
275
+ * owner; a scoped controller serves exactly the agents composed under it.
276
+ * @param owner - the job's owner, or undefined for unowned work.
277
+ * @returns whether some reachable controller serves the owner.
278
+ */
279
+ servesOwner(owner) {
280
+ if (!this.layers.global.controllers.isEmpty()) return true;
281
+ return this.layers.chainLayers(owner === void 0 ? void 0 : scopeOf(owner.ctx)).some((layer) => !layer.controllers.isEmpty());
282
+ }
283
+ /** Count authoritative active records for one exact owner or the shared unowned bucket. */
284
+ activeTaskCount(owner) {
285
+ let count = 0;
286
+ for (const job of this.store.values()) if (job.owner === owner && (job.status === "running" || job.status === "stopping")) count += 1;
287
+ return count;
288
+ }
289
+ /**
290
+ * The completion listeners that own `owner`'s notices: the global layer's
291
+ * first, then each scoped layer along the owner's chain. A listener outside
292
+ * that chain belongs to another composition and must not deliver, or the
293
+ * owner reads one notice per mounted preset.
294
+ * @param owner - the settled job's owner, or undefined for unowned work.
295
+ * @returns the listeners to notify, in registration order per layer.
296
+ */
297
+ *listenersFor(owner) {
298
+ yield* this.layers.global.listeners.values();
299
+ const scope = owner === void 0 ? void 0 : scopeOf(owner.ctx);
300
+ for (const layer of this.layers.chainLayers(scope)) yield* layer.listeners.values();
301
+ }
302
+ /** Look up a job or fail loud. */
303
+ expect(id) {
304
+ const job = this.store.get(id);
305
+ if (job === void 0) throw new Error(`unknown job ${id}`);
306
+ return job;
307
+ }
308
+ /**
309
+ * The isolation fence: a job with an owner is reachable only by callers
310
+ * whose session id matches (`!== undefined` semantics — an unowned job is
311
+ * open, and a no-agent caller can never match an owned one).
312
+ */
313
+ assertAccess(job, caller) {
314
+ if (job.owner !== void 0 && job.owner.id !== caller?.id) throw new Error(`job ${job.id} belongs to another session`);
315
+ }
316
+ /** Project a fresh read-only snapshot from the mutable record. */
317
+ snapshot(job) {
318
+ const ownerSession = job.owner?.id;
319
+ return {
320
+ id: job.id,
321
+ kind: job.kind,
322
+ label: job.label,
323
+ ...job.outputLimitBytes !== void 0 ? { outputLimitBytes: job.outputLimitBytes } : {},
324
+ ...ownerSession !== void 0 ? { ownerSession } : {},
325
+ status: job.status,
326
+ ...job.detail !== void 0 ? { detail: job.detail } : {},
327
+ startedAt: job.startedAt,
328
+ ...job.finishedAt !== void 0 ? { finishedAt: job.finishedAt } : {},
329
+ reported: job.reported
330
+ };
331
+ }
332
+ /**
333
+ * The change observers that own `owner`'s updates, resolved exactly like
334
+ * {@link listenersFor}: the global layer — a host composition's own carrier,
335
+ * which serves every owner — then each scoped layer along the owner's chain.
336
+ * An observer outside that chain belongs to another composition and would
337
+ * otherwise be told about agents it does not compose.
338
+ * @param owner - the owner whose visible set moved, or undefined for unowned work.
339
+ * @returns the observers to notify, in registration order per layer.
340
+ */
341
+ *changedFor(owner) {
342
+ yield* this.layers.global.changed.values();
343
+ const scope = owner === void 0 ? void 0 : scopeOf(owner.ctx);
344
+ for (const layer of this.layers.chainLayers(scope)) yield* layer.changed.values();
345
+ }
346
+ /**
347
+ * Announce that one owner's visible set changed. Each listener is contained
348
+ * so an observer cannot break a lifecycle commit that already happened.
349
+ */
350
+ notifyChanged(owner) {
351
+ for (const listener of this.changedFor(owner)) try {
352
+ listener(owner);
353
+ } catch (error) {
354
+ this.selfCtx.logger.warn(`jobs: onJobsChanged listener threw: ${String(error)}`);
355
+ }
356
+ }
357
+ /**
358
+ * Record the first terminal outcome, release waiters, then announce
359
+ * completion. First-wins preserves a teardown force-failure against late
360
+ * producer settlement. Pending waits mark the job reported before listeners
361
+ * run. Completion is announced last because a reporter may open a model turn
362
+ * synchronously: every other observer of this settlement must already have
363
+ * seen the committed record.
364
+ */
365
+ settle(job, outcome) {
366
+ if (isTerminal(job.status)) return;
367
+ job.status = outcome.status;
368
+ job.detail = outcome.detail;
369
+ job.output = outcome.output;
370
+ job.finishedAt = Date.now();
371
+ if (job.waiters > 0) job.reported = true;
372
+ const snapshot = this.snapshot(job);
373
+ const waitResolvers = [...job.waitResolvers];
374
+ job.waitResolvers.clear();
375
+ for (const resolveWait of waitResolvers) resolveWait();
376
+ job.markSettled();
377
+ this.notifyChanged(job.owner);
378
+ if (this.listenersClosed) return;
379
+ for (const listener of this.listenersFor(job.owner)) try {
380
+ const returned = listener(snapshot, job.owner);
381
+ Promise.resolve(returned).catch((error) => {
382
+ this.selfCtx.logger.warn(`jobs: onJobDone listener rejected for ${job.id}: ${String(error)}`);
383
+ });
384
+ } catch (error) {
385
+ this.selfCtx.logger.warn(`jobs: onJobDone listener threw for ${job.id}: ${String(error)}`);
386
+ }
387
+ }
388
+ /**
389
+ * Attach one awaited cleanup through the exact owner's scope. This survives
390
+ * producer reloads and joins agent quiescence; the retained disposer lets
391
+ * service teardown detach the cross-fiber effect. Fails when the registry is
392
+ * absent or the owner is not its currently registered instance.
393
+ */
394
+ ensureOwnerCleanup(owner) {
395
+ const ownerId = owner.id;
396
+ const agents = this.selfCtx.get("agents");
397
+ if (agents === void 0) throw new Error("background job ownership requires the agent registry (load @stackstackstack/dsh-agent)");
398
+ if (agents.get(ownerId) !== owner) throw new Error(`agent "${ownerId}" is not the registered agent instance (background job owner must be live)`);
399
+ if (this.ownerCleanups.has(owner)) return;
400
+ const detach = owner.ctx.effect(() => async () => {
401
+ this.ownerCleanups.delete(owner);
402
+ await this.disposeOwned(owner);
403
+ }, "jobs.ownerCleanup()");
404
+ this.ownerCleanups.set(owner, detach);
405
+ }
406
+ /** Cancel, await terminal records, and drop every job owned by one exact agent lifecycle. */
407
+ async disposeOwned(owner) {
408
+ const owned = [...this.store.values()].filter((job) => job.owner === owner);
409
+ this.cancelForTeardown(owned, "owner disposed");
410
+ await Promise.all(owned.map((job) => job.settled));
411
+ for (const job of owned) this.store.delete(job.id);
412
+ if (owned.length > 0) this.notifyChanged(owner);
413
+ }
414
+ /**
415
+ * Close listeners, cancel live jobs, await settlement, and detach owner
416
+ * effects. Throwing cancels are force-failed to avoid teardown deadlock.
417
+ */
418
+ async disposeAll() {
419
+ this.listenersClosed = true;
420
+ const all = [...this.store.values()];
421
+ this.cancelForTeardown(all, "jobs service disposed");
422
+ await Promise.all(all.map((job) => job.settled));
423
+ const emptied = new Set(all.map((job) => job.owner));
424
+ this.store.clear();
425
+ for (const owner of emptied) this.notifyChanged(owner);
426
+ const ownerCleanups = [...this.ownerCleanups.values()];
427
+ this.ownerCleanups.clear();
428
+ await Promise.all(ownerCleanups.map((cleanup) => Promise.resolve(cleanup())));
429
+ }
430
+ /**
431
+ * Cancel jobs during teardown with per-job containment. A throwing cancel
432
+ * force-fails the record and reports a possible orphan; a cancel that returns
433
+ * without settling remains indistinguishable from a slow stop and may stall.
434
+ */
435
+ cancelForTeardown(jobs, reason) {
436
+ for (const job of jobs) {
437
+ if (isTerminal(job.status)) continue;
438
+ job.reported = true;
439
+ try {
440
+ job.cancel(reason);
441
+ job.status = "stopping";
442
+ this.notifyChanged(job.owner);
443
+ } catch (error) {
444
+ const detail = `cancel threw during teardown; work may be orphaned: ${String(error)}`;
445
+ this.selfCtx.logger.warn(`jobs: cancel of ${job.id} threw during teardown; job record forced failed and work may be orphaned: ${String(error)}`);
446
+ this.settle(job, {
447
+ status: "failed",
448
+ detail
449
+ });
450
+ }
451
+ }
452
+ }
453
+ };
454
+ //#endregion
455
+ export { LocalJobRegistry, LocalJobRegistry as default, TASK_WAIT_TIMEOUT };
@@ -0,0 +1,26 @@
1
+ //#region lib/types/invariant.js
2
+ /**
3
+ * Package-owned invariant companion for `@stackstackstack/dsh-jobs-local`.
4
+ * @module @stackstackstack/dsh-jobs-local/invariant
5
+ */
6
+ const PACKAGE_NAME = "@stackstackstack/dsh-jobs-local";
7
+ /** Cordis companion plugin name. */
8
+ const name = "jobs-local-invariant";
9
+ /** Service required before the companion can reserve package ownership. */
10
+ const inject = ["invariants"];
11
+ /**
12
+ * No runtime invariant: `@stackstackstack/dsh-jobs/invariant` owns per-snapshot identity, status,
13
+ * timestamp, and owner checks. This provider's admission decision uses private configuration and
14
+ * must fail before a backend starter runs; `LocalJobRegistry.start()` enforces it synchronously
15
+ * for current producers. Repeating an aggregate after publication would expose private
16
+ * configuration solely to this companion and would not verify the fail-closed pre-start guarantee.
17
+ */
18
+ const install = () => {};
19
+ /**
20
+ * Register this package's invariant companion.
21
+ * @param ctx - Cordis context carrying the invariant service.
22
+ * @returns the installed registration's disposer after setup succeeds.
23
+ */
24
+ const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
25
+ //#endregion
26
+ export { apply, inject, name };
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Process-local provider for the background-job capability seam
3
+ * (`ctx.jobs`). It keeps every record in memory and hands out fresh
4
+ * snapshots, never live state.
5
+ *
6
+ * Registrations outlive producer and controller fibers. Agent or service
7
+ * disposal cancels live work and awaits compliant producers; a throwing
8
+ * teardown cancel force-fails only the record and reports a possible orphan.
9
+ * @module @stackstackstack/dsh-jobs-local
10
+ */
11
+ import { Context } from '@deepseek-ai/cordis';
12
+ import z from '@deepseek-ai/schemastery';
13
+ import type { Agent } from '@stackstackstack/dsh-agent';
14
+ import { JobRegistry, JobId } from '@stackstackstack/dsh-jobs';
15
+ import type { JobDoneListener, JobRead, JobSnapshot, JobStart, JobsChangedListener } from '@stackstackstack/dsh-jobs';
16
+ /** Timeout code that distinguishes a bounded wait from caller cancellation. */
17
+ export declare const TASK_WAIT_TIMEOUT = "TASK_WAIT_TIMEOUT";
18
+ /** Configuration for the process-local job registry. */
19
+ export interface Config {
20
+ /**
21
+ * Maximum `running` plus `stopping` jobs per exact owner or in the shared unowned bucket;
22
+ * omission defaults to 10.
23
+ */
24
+ maxConcurrentJobsPerOwner?: number;
25
+ }
26
+ /**
27
+ * The in-memory `jobs` registry. See the Service Definition contract in
28
+ * `@stackstackstack/dsh-jobs` for the ownership, isolation, and lifecycle
29
+ * semantics this implementation honors.
30
+ */
31
+ export declare class LocalJobRegistry extends JobRegistry {
32
+ static Config: z<Config>;
33
+ /** Schemastery-defaulted active-job limit. */
34
+ private readonly maxConcurrentJobsPerOwner;
35
+ private store;
36
+ private counters;
37
+ /**
38
+ * Surfaces and listeners layered by the scope that registered them, in the
39
+ * tools-registry shape: a contribution files into its registering context's
40
+ * scope, and a read unions the global layer with the reader's scope chain.
41
+ *
42
+ * The registry is one process-wide instance serving every composition, so a
43
+ * flat table would answer a per-owner question process-wide: one preset's
44
+ * job controls would hold `start()` open for an agent whose own composition
45
+ * loads none, and one settlement would reach every preset's notice listener.
46
+ * Layers make both reads owner-relative. Nothing derives a cache from a
47
+ * layer, so change notification is a no-op.
48
+ */
49
+ private readonly layers;
50
+ private listenersClosed;
51
+ /** Owner agents with attached scope cleanup, mapped to the exact disposer. */
52
+ private ownerCleanups;
53
+ /** Service context used by detached settlement continuations and teardown. */
54
+ private readonly selfCtx;
55
+ constructor(ctx: Context, config: Config);
56
+ start(spec: JobStart): JobId;
57
+ list(caller?: Agent): JobSnapshot[];
58
+ get(id: JobId, caller?: Agent): JobSnapshot;
59
+ read(id: JobId, caller?: Agent): JobRead;
60
+ kill(id: JobId, caller?: Agent, reason?: string): 'requested' | 'already-finished';
61
+ wait(id: JobId, timeoutMs: number, caller?: Agent, signal?: AbortSignal): Promise<JobSnapshot>;
62
+ onJobDone(listener: JobDoneListener): () => void;
63
+ onJobsChanged(listener: JobsChangedListener): () => void;
64
+ attachController(name: string): () => void;
65
+ /**
66
+ * Whether an attached job controller can collect and stop work owned by
67
+ * `owner`. The global layer holds every controller attached from an unscoped
68
+ * context — a host composition's own controls — and therefore serves every
69
+ * owner; a scoped controller serves exactly the agents composed under it.
70
+ * @param owner - the job's owner, or undefined for unowned work.
71
+ * @returns whether some reachable controller serves the owner.
72
+ */
73
+ private servesOwner;
74
+ /** Count authoritative active records for one exact owner or the shared unowned bucket. */
75
+ private activeTaskCount;
76
+ /**
77
+ * The completion listeners that own `owner`'s notices: the global layer's
78
+ * first, then each scoped layer along the owner's chain. A listener outside
79
+ * that chain belongs to another composition and must not deliver, or the
80
+ * owner reads one notice per mounted preset.
81
+ * @param owner - the settled job's owner, or undefined for unowned work.
82
+ * @returns the listeners to notify, in registration order per layer.
83
+ */
84
+ private listenersFor;
85
+ /** Look up a job or fail loud. */
86
+ private expect;
87
+ /**
88
+ * The isolation fence: a job with an owner is reachable only by callers
89
+ * whose session id matches (`!== undefined` semantics — an unowned job is
90
+ * open, and a no-agent caller can never match an owned one).
91
+ */
92
+ private assertAccess;
93
+ /** Project a fresh read-only snapshot from the mutable record. */
94
+ private snapshot;
95
+ /**
96
+ * The change observers that own `owner`'s updates, resolved exactly like
97
+ * {@link listenersFor}: the global layer — a host composition's own carrier,
98
+ * which serves every owner — then each scoped layer along the owner's chain.
99
+ * An observer outside that chain belongs to another composition and would
100
+ * otherwise be told about agents it does not compose.
101
+ * @param owner - the owner whose visible set moved, or undefined for unowned work.
102
+ * @returns the observers to notify, in registration order per layer.
103
+ */
104
+ private changedFor;
105
+ /**
106
+ * Announce that one owner's visible set changed. Each listener is contained
107
+ * so an observer cannot break a lifecycle commit that already happened.
108
+ */
109
+ private notifyChanged;
110
+ /**
111
+ * Record the first terminal outcome, release waiters, then announce
112
+ * completion. First-wins preserves a teardown force-failure against late
113
+ * producer settlement. Pending waits mark the job reported before listeners
114
+ * run. Completion is announced last because a reporter may open a model turn
115
+ * synchronously: every other observer of this settlement must already have
116
+ * seen the committed record.
117
+ */
118
+ private settle;
119
+ /**
120
+ * Attach one awaited cleanup through the exact owner's scope. This survives
121
+ * producer reloads and joins agent quiescence; the retained disposer lets
122
+ * service teardown detach the cross-fiber effect. Fails when the registry is
123
+ * absent or the owner is not its currently registered instance.
124
+ */
125
+ private ensureOwnerCleanup;
126
+ /** Cancel, await terminal records, and drop every job owned by one exact agent lifecycle. */
127
+ private disposeOwned;
128
+ /**
129
+ * Close listeners, cancel live jobs, await settlement, and detach owner
130
+ * effects. Throwing cancels are force-failed to avoid teardown deadlock.
131
+ */
132
+ private disposeAll;
133
+ /**
134
+ * Cancel jobs during teardown with per-job containment. A throwing cancel
135
+ * force-fails the record and reports a possible orphan; a cancel that returns
136
+ * without settling remains indistinguishable from a slow stop and may stall.
137
+ */
138
+ private cancelForTeardown;
139
+ }
140
+ export default LocalJobRegistry;
141
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Package-owned invariant companion for `@stackstackstack/dsh-jobs-local`.
3
+ * @module @stackstackstack/dsh-jobs-local/invariant
4
+ */
5
+ import type { Context } from '@deepseek-ai/cordis';
6
+ /** Cordis companion plugin name. */
7
+ export declare const name = "jobs-local-invariant";
8
+ /** Service required before the companion can reserve package ownership. */
9
+ export declare const inject: string[];
10
+ /**
11
+ * Register this package's invariant companion.
12
+ * @param ctx - Cordis context carrying the invariant service.
13
+ * @returns the installed registration's disposer after setup succeeds.
14
+ */
15
+ export declare const apply: (ctx: Context) => Promise<() => void>;
16
+ //# sourceMappingURL=invariant.d.ts.map
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "@stackstackstack/dsh-jobs-local",
3
+ "description": "Process-local implementation of the DeepSeek Harness background job registry seam",
4
+ "version": "0.1.5",
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
11
+ "directory": "packages/jobs/jobs-local"
12
+ },
13
+ "type": "module",
14
+ "main": "lib/index.js",
15
+ "types": "lib/types/index.d.ts",
16
+ "exports": {
17
+ ".": {
18
+ "types": "./lib/types/index.d.ts",
19
+ "default": "./lib/index.js"
20
+ },
21
+ "./invariant": {
22
+ "types": "./lib/types/invariant.d.ts",
23
+ "default": "./lib/invariant.js"
24
+ },
25
+ "./src/*": "./src/*",
26
+ "./package.json": "./package.json"
27
+ },
28
+ "files": [
29
+ "lib/index.js",
30
+ "lib/invariant.js",
31
+ "lib/types/**/*.d.ts"
32
+ ],
33
+ "license": "MIT",
34
+ "peerDependencies": {
35
+ "@stackstackstack/dsh-invariants": "^0.1.5",
36
+ "@stackstackstack/dsh-scope": "^0.1.5",
37
+ "@stackstackstack/dsh-agent": "^0.1.5",
38
+ "@stackstackstack/dsh-timeout": "^0.1.5",
39
+ "@stackstackstack/dsh-jobs": "^0.1.5",
40
+ "@deepseek-ai/cordis": "^4.0.1"
41
+ },
42
+ "dependencies": {
43
+ "@deepseek-ai/schemastery": "^3.18.1"
44
+ },
45
+ "devDependencies": {
46
+ "@stackstackstack/dsh-agent": "^0.1.5",
47
+ "@deepseek-ai/cordis-plugin-include": "^1.0.6",
48
+ "@stackstackstack/dsh-scope": "^0.1.5",
49
+ "@stackstackstack/dsh-invariants": "^0.1.5",
50
+ "@stackstackstack/dsh-brand": "^0.1.5",
51
+ "@stackstackstack/dsh-jobs": "^0.1.5",
52
+ "@stackstackstack/dsh-session": "^0.1.5",
53
+ "@stackstackstack/dsh-timeout": "^0.1.5",
54
+ "@deepseek-ai/cordis-plugin-loader": "^1.0.2",
55
+ "@deepseek-ai/cordis": "^4.0.1"
56
+ }
57
+ }