@ryan_nookpi/pi-extension-subagent 0.5.7 → 0.5.9
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/async-task-lifecycle.ts +281 -0
- package/async-task-provider.ts +623 -0
- package/commands.ts +446 -416
- package/index.ts +20 -2
- package/package.json +3 -1
- package/runner.ts +33 -1
- package/store.ts +1 -0
- package/tool-execute.ts +72 -24
|
@@ -0,0 +1,623 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
// Kept package-local so the published extension needs no sibling package.
|
|
4
|
+
export const ASYNC_TASK_CHANNEL = "pi.async-tasks.v1";
|
|
5
|
+
export interface TaskOwner {
|
|
6
|
+
sessionId: string;
|
|
7
|
+
piSessionId: string;
|
|
8
|
+
runtimeInstanceId: string;
|
|
9
|
+
providerId: string;
|
|
10
|
+
providerInstanceId: string;
|
|
11
|
+
}
|
|
12
|
+
export interface TrackedTask extends TaskOwner {
|
|
13
|
+
taskId: string;
|
|
14
|
+
rootTaskId: string;
|
|
15
|
+
parentTaskId?: string;
|
|
16
|
+
invocationId?: string;
|
|
17
|
+
kind: string;
|
|
18
|
+
title: string;
|
|
19
|
+
execution: "queued" | "running" | "cancelling" | "succeeded" | "failed" | "cancelled" | "interrupted";
|
|
20
|
+
presence: "active" | "settled" | "unknown";
|
|
21
|
+
registration: "reserved" | "approved" | "starting" | "spawned" | "abandoned";
|
|
22
|
+
grantId?: string;
|
|
23
|
+
providerRevision: number;
|
|
24
|
+
controlGeneration: number;
|
|
25
|
+
createdAt: string;
|
|
26
|
+
updatedAt: string;
|
|
27
|
+
details?: Record<string, unknown>;
|
|
28
|
+
}
|
|
29
|
+
interface Ticket extends TaskOwner {
|
|
30
|
+
completionId: string;
|
|
31
|
+
rootTaskId: string;
|
|
32
|
+
target: "model";
|
|
33
|
+
state: "pending" | "submitted" | "suppressed" | "failed";
|
|
34
|
+
controlGeneration: number;
|
|
35
|
+
deliveryId?: string;
|
|
36
|
+
failureReason?: string;
|
|
37
|
+
}
|
|
38
|
+
export interface ProviderBus {
|
|
39
|
+
on(channel: string, listener: (message: unknown) => void): () => void;
|
|
40
|
+
emit(channel: string, message: unknown): void;
|
|
41
|
+
}
|
|
42
|
+
interface Hooks {
|
|
43
|
+
cancel(taskId: string): void | Promise<void>;
|
|
44
|
+
detail(taskId: string): string | undefined;
|
|
45
|
+
close(): void;
|
|
46
|
+
reopen?(): void;
|
|
47
|
+
observed?(taskIds: string[]): void;
|
|
48
|
+
}
|
|
49
|
+
type Envelope = Record<string, unknown>;
|
|
50
|
+
const capabilities = {
|
|
51
|
+
registration: true,
|
|
52
|
+
snapshot: true,
|
|
53
|
+
cancel: true,
|
|
54
|
+
detail: true,
|
|
55
|
+
closeAdmission: true,
|
|
56
|
+
suppressDelivery: true,
|
|
57
|
+
};
|
|
58
|
+
const ownerFields = ["sessionId", "piSessionId", "runtimeInstanceId", "providerId", "providerInstanceId"] as const;
|
|
59
|
+
const isId = (value: unknown): value is string => typeof value === "string" && value.length > 0 && value.length <= 256;
|
|
60
|
+
|
|
61
|
+
/** Registration is a grant protocol, not an event notification. */
|
|
62
|
+
export class AsyncTaskProvider {
|
|
63
|
+
private owner?: TaskOwner;
|
|
64
|
+
private discovery?: { requestId: string; piSessionId: string; providerInstanceId: string };
|
|
65
|
+
private revision = 0;
|
|
66
|
+
private generation = 0;
|
|
67
|
+
private open = false;
|
|
68
|
+
private ready = false;
|
|
69
|
+
private stopped = false;
|
|
70
|
+
private discoveryClosed = false;
|
|
71
|
+
private requestedPiSessionId?: string;
|
|
72
|
+
private bindingEpoch = 0;
|
|
73
|
+
private discoveryPromise?: Promise<void>;
|
|
74
|
+
private resolveDiscovery?: () => void;
|
|
75
|
+
private unsubscribe?: () => void;
|
|
76
|
+
private readonly tasks = new Map<string, TrackedTask>();
|
|
77
|
+
private readonly tickets = new Map<string, Ticket>();
|
|
78
|
+
private readonly payloads = new Map<string, unknown>();
|
|
79
|
+
private readonly observed = new Set<string>();
|
|
80
|
+
private readonly abandoned = new Set<string>();
|
|
81
|
+
private readonly spawned = new Set<string>();
|
|
82
|
+
private readonly pending = new Map<string, { taskId: string; resolve: (message?: Envelope) => void }>();
|
|
83
|
+
private readonly controls = new Map<string, Promise<Envelope>>();
|
|
84
|
+
|
|
85
|
+
constructor(
|
|
86
|
+
private readonly bus: ProviderBus | undefined,
|
|
87
|
+
private readonly providerId: string,
|
|
88
|
+
private readonly version: string,
|
|
89
|
+
private readonly hooks: Hooks,
|
|
90
|
+
private readonly timeoutMs = 2_000,
|
|
91
|
+
) {
|
|
92
|
+
this.unsubscribe = bus?.on(ASYNC_TASK_CHANNEL, (message) => this.receive(message));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
bind(piSessionId: string, snapshotReady = true): void {
|
|
96
|
+
if (this.stopped || !isId(piSessionId)) return;
|
|
97
|
+
// A round trip also invalidates starts still awaiting discovery or validation.
|
|
98
|
+
if (this.requestedPiSessionId !== piSessionId) this.bindingEpoch++;
|
|
99
|
+
// Fence new admission even when the old owner must remain for resource callbacks.
|
|
100
|
+
this.requestedPiSessionId = piSessionId;
|
|
101
|
+
if (this.discovery?.piSessionId === piSessionId) {
|
|
102
|
+
if (this.ready !== snapshotReady) {
|
|
103
|
+
this.ready = snapshotReady;
|
|
104
|
+
this.announce();
|
|
105
|
+
}
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
// Rebinding a live owner would orphan obligations. Runtime replacement owns that transition.
|
|
109
|
+
if ([...this.tasks.keys()].some((id) => this.retained(id)))
|
|
110
|
+
throw new Error("Async task provider still owns tasks from another session");
|
|
111
|
+
this.tasks.clear();
|
|
112
|
+
this.tickets.clear();
|
|
113
|
+
this.payloads.clear();
|
|
114
|
+
this.observed.clear();
|
|
115
|
+
this.abandoned.clear();
|
|
116
|
+
this.spawned.clear();
|
|
117
|
+
this.revision = 0;
|
|
118
|
+
this.generation = 0;
|
|
119
|
+
this.resolveDiscovery?.();
|
|
120
|
+
this.discoveryClosed = false;
|
|
121
|
+
this.controls.clear();
|
|
122
|
+
this.owner = undefined;
|
|
123
|
+
this.open = false;
|
|
124
|
+
this.ready = snapshotReady;
|
|
125
|
+
this.discovery = { requestId: randomUUID(), piSessionId, providerInstanceId: randomUUID() };
|
|
126
|
+
if (this.bus)
|
|
127
|
+
this.discoveryPromise = new Promise((resolve) => {
|
|
128
|
+
const timer = setTimeout(() => {
|
|
129
|
+
this.discoveryClosed = true;
|
|
130
|
+
resolve();
|
|
131
|
+
}, this.timeoutMs);
|
|
132
|
+
this.resolveDiscovery = () => {
|
|
133
|
+
clearTimeout(timer);
|
|
134
|
+
resolve();
|
|
135
|
+
};
|
|
136
|
+
});
|
|
137
|
+
this.bus?.emit(ASYNC_TASK_CHANNEL, {
|
|
138
|
+
contract: ASYNC_TASK_CHANNEL,
|
|
139
|
+
type: "host-query",
|
|
140
|
+
...this.discovery,
|
|
141
|
+
sessionId: null,
|
|
142
|
+
runtimeInstanceId: null,
|
|
143
|
+
providerId: this.providerId,
|
|
144
|
+
providerRevision: this.revision,
|
|
145
|
+
controlGeneration: this.generation,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
get bindingToken(): number {
|
|
150
|
+
return this.bindingEpoch;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async whenDiscovered(): Promise<void> {
|
|
154
|
+
await this.discoveryPromise;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
get supported(): boolean {
|
|
158
|
+
return !!this.owner && this.ready;
|
|
159
|
+
}
|
|
160
|
+
get accepting(): boolean {
|
|
161
|
+
return this.supported && this.open && !this.stopped && this.requestedPiSessionId === this.owner?.piSessionId;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private announce(): void {
|
|
165
|
+
if (!this.owner) return;
|
|
166
|
+
this.emit("provider-ready", {
|
|
167
|
+
providerVersion: this.version,
|
|
168
|
+
contractVersion: 1,
|
|
169
|
+
snapshotReady: this.ready,
|
|
170
|
+
capabilities,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private emit(type: string, fields: Envelope = {}, requestId: string = randomUUID()): void {
|
|
175
|
+
if (!this.owner) return;
|
|
176
|
+
this.bus?.emit(ASYNC_TASK_CHANNEL, {
|
|
177
|
+
contract: ASYNC_TASK_CHANNEL,
|
|
178
|
+
...this.owner,
|
|
179
|
+
requestId,
|
|
180
|
+
providerRevision: this.revision,
|
|
181
|
+
controlGeneration: this.generation,
|
|
182
|
+
type,
|
|
183
|
+
...fields,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private snapshot(): { tasks: TrackedTask[]; tickets: Ticket[] } {
|
|
188
|
+
return structuredClone({ tasks: [...this.tasks.values()], tickets: [...this.tickets.values()] });
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
private publish(task?: TrackedTask): void {
|
|
192
|
+
this.revision++;
|
|
193
|
+
if (task) {
|
|
194
|
+
task.providerRevision = this.revision;
|
|
195
|
+
task.updatedAt = new Date().toISOString();
|
|
196
|
+
}
|
|
197
|
+
this.emit("task-update", { detail: this.snapshot() });
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private receive(value: unknown): void {
|
|
201
|
+
if (!value || typeof value !== "object") return;
|
|
202
|
+
const message = value as Envelope;
|
|
203
|
+
if (message.contract !== ASYNC_TASK_CHANNEL || !isId(message.requestId)) return;
|
|
204
|
+
if (message.type === "host-state" && this.discovery && !this.owner && !this.discoveryClosed && !this.stopped) {
|
|
205
|
+
if (
|
|
206
|
+
message.requestId !== this.discovery.requestId ||
|
|
207
|
+
message.piSessionId !== this.discovery.piSessionId ||
|
|
208
|
+
message.providerInstanceId !== this.discovery.providerInstanceId ||
|
|
209
|
+
message.providerId !== this.providerId ||
|
|
210
|
+
!isId(message.sessionId) ||
|
|
211
|
+
!isId(message.runtimeInstanceId) ||
|
|
212
|
+
message.supported !== true
|
|
213
|
+
)
|
|
214
|
+
return;
|
|
215
|
+
const offered = message.capabilities as Envelope | undefined;
|
|
216
|
+
if (
|
|
217
|
+
!offered ||
|
|
218
|
+
Object.keys(capabilities).some((key) => offered[key] !== true) ||
|
|
219
|
+
!Number.isSafeInteger(message.controlGeneration) ||
|
|
220
|
+
Number(message.controlGeneration) < 0
|
|
221
|
+
)
|
|
222
|
+
return;
|
|
223
|
+
this.owner = {
|
|
224
|
+
sessionId: message.sessionId,
|
|
225
|
+
runtimeInstanceId: message.runtimeInstanceId,
|
|
226
|
+
piSessionId: this.discovery.piSessionId,
|
|
227
|
+
providerId: this.providerId,
|
|
228
|
+
providerInstanceId: this.discovery.providerInstanceId,
|
|
229
|
+
};
|
|
230
|
+
this.generation = Number(message.controlGeneration);
|
|
231
|
+
this.open = message.admissionState === "open";
|
|
232
|
+
this.resolveDiscovery?.();
|
|
233
|
+
this.announce();
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
if (!this.owner || ownerFields.some((key) => message[key] !== this.owner?.[key])) return;
|
|
237
|
+
if (
|
|
238
|
+
message.type === "host-state" &&
|
|
239
|
+
Number.isSafeInteger(message.controlGeneration) &&
|
|
240
|
+
Number(message.controlGeneration) > this.generation
|
|
241
|
+
) {
|
|
242
|
+
this.generation = Number(message.controlGeneration);
|
|
243
|
+
this.open = !this.stopped && message.supported === true && message.admissionState === "open";
|
|
244
|
+
if (this.open) this.hooks.reopen?.();
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
if (message.type === "task-register-result") {
|
|
248
|
+
const pending = this.pending.get(message.requestId);
|
|
249
|
+
if (pending && pending.taskId === message.taskId) pending.resolve(message);
|
|
250
|
+
} else if (message.type === "snapshot-request") {
|
|
251
|
+
this.emit("snapshot", { watermark: this.revision, detail: this.snapshot() }, message.requestId);
|
|
252
|
+
} else if (
|
|
253
|
+
message.type === "completion-observed" &&
|
|
254
|
+
isId(message.deliveryId) &&
|
|
255
|
+
Array.isArray(message.completionIds)
|
|
256
|
+
) {
|
|
257
|
+
for (const id of message.completionIds) {
|
|
258
|
+
const ticket = typeof id === "string" ? this.tickets.get(id) : undefined;
|
|
259
|
+
if (ticket?.deliveryId === message.deliveryId) this.observed.add(ticket.completionId);
|
|
260
|
+
}
|
|
261
|
+
const deliveryTickets = [...this.tickets.values()].filter((ticket) => ticket.deliveryId === message.deliveryId);
|
|
262
|
+
if (deliveryTickets.length && deliveryTickets.every((ticket) => this.observed.has(ticket.completionId))) {
|
|
263
|
+
this.payloads.delete(message.deliveryId);
|
|
264
|
+
this.hooks.observed?.(deliveryTickets.map((ticket) => ticket.rootTaskId));
|
|
265
|
+
}
|
|
266
|
+
} else if (message.type === "control-request") {
|
|
267
|
+
let operation = this.controls.get(message.requestId);
|
|
268
|
+
if (!operation) {
|
|
269
|
+
operation = this.control(message).catch((error: unknown) => ({
|
|
270
|
+
outcome: "blocked_cleanup",
|
|
271
|
+
admissionClosed: !this.open,
|
|
272
|
+
submittedDeliveryIds: [],
|
|
273
|
+
reason: String(error).slice(0, 4096),
|
|
274
|
+
}));
|
|
275
|
+
this.controls.set(message.requestId, operation);
|
|
276
|
+
}
|
|
277
|
+
const owner = this.owner;
|
|
278
|
+
void operation.then((result) => {
|
|
279
|
+
if (this.owner === owner) this.emit("control-result", result, String(message.requestId));
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
private request(type: string, taskId: string, fields: Envelope = {}): Promise<Envelope | undefined> {
|
|
285
|
+
if (this.pending.size >= 128) return Promise.resolve(undefined);
|
|
286
|
+
const requestId = randomUUID();
|
|
287
|
+
return new Promise((resolve) => {
|
|
288
|
+
const timer = setTimeout(() => finish(), this.timeoutMs);
|
|
289
|
+
const finish = (reply?: Envelope) => {
|
|
290
|
+
clearTimeout(timer);
|
|
291
|
+
this.pending.delete(requestId);
|
|
292
|
+
resolve(reply);
|
|
293
|
+
};
|
|
294
|
+
this.pending.set(requestId, { taskId, resolve: finish });
|
|
295
|
+
this.emit(type, { taskId, ...fields }, requestId);
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async reserve(
|
|
300
|
+
input: {
|
|
301
|
+
taskId?: string;
|
|
302
|
+
rootTaskId?: string;
|
|
303
|
+
title: string;
|
|
304
|
+
kind: string;
|
|
305
|
+
details?: Record<string, unknown>;
|
|
306
|
+
invocationId?: string;
|
|
307
|
+
},
|
|
308
|
+
signal?: AbortSignal,
|
|
309
|
+
): Promise<string | undefined> {
|
|
310
|
+
const owner = this.owner;
|
|
311
|
+
const binding = this.bindingToken;
|
|
312
|
+
if (!this.supported || !owner) return undefined;
|
|
313
|
+
if (!this.accepting || signal?.aborted) throw new Error("Async task admission is closed");
|
|
314
|
+
const taskId = input.taskId ?? randomUUID();
|
|
315
|
+
if (this.pending.size >= 128) throw new Error("Async task registration request limit reached");
|
|
316
|
+
if (!isId(taskId) || !isId(input.kind) || (input.invocationId !== undefined && !isId(input.invocationId)))
|
|
317
|
+
throw new Error("Invalid async task identity");
|
|
318
|
+
if (this.tasks.has(taskId)) throw new Error("Async task attempt already exists");
|
|
319
|
+
if (input.rootTaskId && !this.tasks.has(input.rootTaskId)) throw new Error("Async task root is missing");
|
|
320
|
+
const now = new Date().toISOString();
|
|
321
|
+
const task: TrackedTask = {
|
|
322
|
+
...owner,
|
|
323
|
+
taskId,
|
|
324
|
+
rootTaskId: input.rootTaskId ?? taskId,
|
|
325
|
+
...(input.rootTaskId ? { parentTaskId: input.rootTaskId } : {}),
|
|
326
|
+
...(input.invocationId ? { invocationId: input.invocationId } : {}),
|
|
327
|
+
kind: input.kind,
|
|
328
|
+
title: input.title.slice(0, 500) || input.kind,
|
|
329
|
+
execution: "queued",
|
|
330
|
+
presence: "settled",
|
|
331
|
+
registration: "reserved",
|
|
332
|
+
providerRevision: ++this.revision,
|
|
333
|
+
controlGeneration: this.generation,
|
|
334
|
+
createdAt: now,
|
|
335
|
+
updatedAt: now,
|
|
336
|
+
...(input.details ? { details: input.details } : {}),
|
|
337
|
+
};
|
|
338
|
+
if (Buffer.byteLength(JSON.stringify(task.details ?? {})) > 16_384)
|
|
339
|
+
throw new Error("Async task details exceed wire limit");
|
|
340
|
+
this.tasks.set(taskId, task);
|
|
341
|
+
const onAbort = () => {
|
|
342
|
+
this.abandoned.add(taskId);
|
|
343
|
+
};
|
|
344
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
345
|
+
try {
|
|
346
|
+
// task-register has task, not taskId, in the frozen wire contract.
|
|
347
|
+
let reply = await this.register(task);
|
|
348
|
+
if (!reply && !this.abandoned.has(taskId) && this.accepting && this.bindingToken === binding)
|
|
349
|
+
reply = await this.request("registration-query", taskId);
|
|
350
|
+
if (
|
|
351
|
+
this.bindingToken !== binding ||
|
|
352
|
+
reply?.outcome !== "accepted" ||
|
|
353
|
+
reply.registration !== "approved" ||
|
|
354
|
+
!isId(reply.grantId) ||
|
|
355
|
+
reply.controlGeneration !== task.controlGeneration ||
|
|
356
|
+
!this.accepting ||
|
|
357
|
+
this.generation !== task.controlGeneration ||
|
|
358
|
+
this.abandoned.has(taskId)
|
|
359
|
+
) {
|
|
360
|
+
await this.abandon(taskId);
|
|
361
|
+
throw new Error("Async task registration was not approved; execution did not start");
|
|
362
|
+
}
|
|
363
|
+
task.registration = "approved";
|
|
364
|
+
task.grantId = reply.grantId;
|
|
365
|
+
task.presence = "active";
|
|
366
|
+
this.publish(task);
|
|
367
|
+
return taskId;
|
|
368
|
+
} finally {
|
|
369
|
+
signal?.removeEventListener("abort", onAbort);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
private register(task: TrackedTask): Promise<Envelope | undefined> {
|
|
374
|
+
const requestId = randomUUID();
|
|
375
|
+
return new Promise((resolve) => {
|
|
376
|
+
const timer = setTimeout(() => finish(), this.timeoutMs);
|
|
377
|
+
const finish = (reply?: Envelope) => {
|
|
378
|
+
clearTimeout(timer);
|
|
379
|
+
this.pending.delete(requestId);
|
|
380
|
+
resolve(reply);
|
|
381
|
+
};
|
|
382
|
+
this.pending.set(requestId, { taskId: task.taskId, resolve: finish });
|
|
383
|
+
this.emit("task-register", { task: structuredClone(task) }, requestId);
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
async abandon(taskId: string): Promise<void> {
|
|
388
|
+
const task = this.tasks.get(taskId);
|
|
389
|
+
if (!task || this.spawned.has(taskId)) return;
|
|
390
|
+
this.abandoned.add(taskId);
|
|
391
|
+
const reply = await this.request("registration-abandon", taskId, { neverSpawned: true });
|
|
392
|
+
if (reply?.registration === "abandoned") {
|
|
393
|
+
task.registration = "abandoned";
|
|
394
|
+
task.execution = "cancelled";
|
|
395
|
+
task.presence = "settled";
|
|
396
|
+
} else {
|
|
397
|
+
task.execution = "interrupted";
|
|
398
|
+
task.presence = "unknown";
|
|
399
|
+
}
|
|
400
|
+
this.publish(task);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
canStart(taskId: string): boolean {
|
|
404
|
+
const task = this.tasks.get(taskId);
|
|
405
|
+
return (
|
|
406
|
+
!!task &&
|
|
407
|
+
this.accepting &&
|
|
408
|
+
task.controlGeneration === this.generation &&
|
|
409
|
+
!this.abandoned.has(taskId) &&
|
|
410
|
+
!this.spawned.has(taskId) &&
|
|
411
|
+
task.registration === "approved"
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
start(taskId: string): boolean {
|
|
416
|
+
if (!this.canStart(taskId)) return false;
|
|
417
|
+
this.spawned.add(taskId);
|
|
418
|
+
const task = this.tasks.get(taskId);
|
|
419
|
+
if (!task) return false;
|
|
420
|
+
task.registration = "starting";
|
|
421
|
+
task.execution = "running";
|
|
422
|
+
// Do not call external listeners between consuming the grant and entering
|
|
423
|
+
// the executor. The first resource/result event publishes this revision.
|
|
424
|
+
task.providerRevision = ++this.revision;
|
|
425
|
+
task.updatedAt = new Date().toISOString();
|
|
426
|
+
return true;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
resource(taskId: string, presence: TrackedTask["presence"]): void {
|
|
430
|
+
const task = this.tasks.get(taskId);
|
|
431
|
+
if (!task) return;
|
|
432
|
+
task.presence = presence;
|
|
433
|
+
if (presence === "active") task.registration = "spawned";
|
|
434
|
+
this.publish(task);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
finish(
|
|
438
|
+
taskId: string,
|
|
439
|
+
execution: TrackedTask["execution"],
|
|
440
|
+
presence: TrackedTask["presence"],
|
|
441
|
+
completion = true,
|
|
442
|
+
): void {
|
|
443
|
+
const task = this.tasks.get(taskId);
|
|
444
|
+
const owner = this.owner;
|
|
445
|
+
if (!task || !owner) return;
|
|
446
|
+
task.execution = execution;
|
|
447
|
+
task.presence = presence;
|
|
448
|
+
if (
|
|
449
|
+
completion &&
|
|
450
|
+
task.rootTaskId === taskId &&
|
|
451
|
+
![...this.tickets.values()].some((ticket) => ticket.rootTaskId === taskId)
|
|
452
|
+
) {
|
|
453
|
+
const ticket: Ticket = {
|
|
454
|
+
...owner,
|
|
455
|
+
completionId: randomUUID(),
|
|
456
|
+
rootTaskId: taskId,
|
|
457
|
+
target: "model",
|
|
458
|
+
state:
|
|
459
|
+
this.supported && this.open && !this.stopped && task.controlGeneration === this.generation
|
|
460
|
+
? "pending"
|
|
461
|
+
: "suppressed",
|
|
462
|
+
controlGeneration: task.controlGeneration,
|
|
463
|
+
};
|
|
464
|
+
this.tickets.set(ticket.completionId, ticket);
|
|
465
|
+
}
|
|
466
|
+
this.publish(task);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
discardPending(taskId: string): void {
|
|
470
|
+
for (const ticket of this.tickets.values()) {
|
|
471
|
+
if (ticket.rootTaskId === taskId && !ticket.deliveryId) ticket.state = "suppressed";
|
|
472
|
+
}
|
|
473
|
+
this.publish();
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
failPending(taskId: string, reason: string): void {
|
|
477
|
+
for (const ticket of this.tickets.values()) {
|
|
478
|
+
if (ticket.rootTaskId === taskId && ticket.state === "pending") {
|
|
479
|
+
ticket.state = "failed";
|
|
480
|
+
ticket.failureReason = reason.slice(0, 4096);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
this.publish();
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
retained(taskId: string): boolean {
|
|
487
|
+
const task = this.tasks.get(taskId);
|
|
488
|
+
return (
|
|
489
|
+
!!task &&
|
|
490
|
+
(task.registration === "reserved" ||
|
|
491
|
+
task.presence !== "settled" ||
|
|
492
|
+
[...this.tickets.values()].some(
|
|
493
|
+
(ticket) =>
|
|
494
|
+
ticket.rootTaskId === task.rootTaskId &&
|
|
495
|
+
ticket.state !== "suppressed" &&
|
|
496
|
+
!this.observed.has(ticket.completionId),
|
|
497
|
+
))
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
deliver<T extends { details?: unknown }>(taskIds: string[], message: T, send: (message: T) => void): void {
|
|
502
|
+
const tickets = [...this.tickets.values()].filter(
|
|
503
|
+
(ticket) => taskIds.includes(ticket.rootTaskId) && ticket.state === "pending",
|
|
504
|
+
);
|
|
505
|
+
if (!tickets.length) {
|
|
506
|
+
if (!taskIds.some((id) => this.tasks.has(id))) {
|
|
507
|
+
try {
|
|
508
|
+
send(message);
|
|
509
|
+
} catch {
|
|
510
|
+
/* Preserve unsupported hosts' best-effort delivery. */
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
if (!this.accepting || tickets.some((ticket) => ticket.controlGeneration !== this.generation)) return;
|
|
516
|
+
const owner = this.owner;
|
|
517
|
+
if (!owner) return;
|
|
518
|
+
const deliveryId = randomUUID();
|
|
519
|
+
const annotated = {
|
|
520
|
+
...message,
|
|
521
|
+
details: {
|
|
522
|
+
...(message.details && typeof message.details === "object" ? message.details : {}),
|
|
523
|
+
asyncTasks: {
|
|
524
|
+
...owner,
|
|
525
|
+
deliveryId,
|
|
526
|
+
completionIds: tickets.map((ticket) => ticket.completionId),
|
|
527
|
+
taskIds,
|
|
528
|
+
controlGeneration: this.generation,
|
|
529
|
+
},
|
|
530
|
+
},
|
|
531
|
+
};
|
|
532
|
+
this.payloads.set(deliveryId, annotated);
|
|
533
|
+
for (const ticket of tickets) {
|
|
534
|
+
ticket.state = "submitted";
|
|
535
|
+
ticket.deliveryId = deliveryId;
|
|
536
|
+
}
|
|
537
|
+
const deliveryGeneration = this.generation;
|
|
538
|
+
this.publish();
|
|
539
|
+
if (!this.accepting || this.generation !== deliveryGeneration) {
|
|
540
|
+
for (const ticket of tickets) ticket.state = "suppressed";
|
|
541
|
+
this.payloads.delete(deliveryId);
|
|
542
|
+
this.publish();
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
try {
|
|
546
|
+
send(annotated);
|
|
547
|
+
} catch (error) {
|
|
548
|
+
for (const ticket of tickets) {
|
|
549
|
+
ticket.state = "failed";
|
|
550
|
+
ticket.failureReason = String(error).slice(0, 4096);
|
|
551
|
+
}
|
|
552
|
+
this.publish();
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
private async control(message: Envelope): Promise<Envelope> {
|
|
557
|
+
const base = () => ({
|
|
558
|
+
admissionClosed: !this.open,
|
|
559
|
+
submittedDeliveryIds: [
|
|
560
|
+
...new Set([...this.tickets.values()].flatMap((ticket) => (ticket.deliveryId ? [ticket.deliveryId] : []))),
|
|
561
|
+
],
|
|
562
|
+
});
|
|
563
|
+
if (!Number.isSafeInteger(message.controlGeneration) || Number(message.controlGeneration) < this.generation)
|
|
564
|
+
return { ...base(), outcome: "stale" };
|
|
565
|
+
if (message.action === "closeAdmission") {
|
|
566
|
+
this.open = false;
|
|
567
|
+
this.generation = Number(message.controlGeneration);
|
|
568
|
+
this.hooks.close();
|
|
569
|
+
for (const task of this.tasks.values())
|
|
570
|
+
if (!this.spawned.has(task.taskId)) {
|
|
571
|
+
this.abandoned.add(task.taskId);
|
|
572
|
+
void this.abandon(task.taskId);
|
|
573
|
+
}
|
|
574
|
+
for (const ticket of this.tickets.values())
|
|
575
|
+
if (ticket.state === "pending" || (ticket.state === "failed" && !ticket.deliveryId))
|
|
576
|
+
ticket.state = "suppressed";
|
|
577
|
+
this.publish();
|
|
578
|
+
return { ...base(), outcome: "settled" };
|
|
579
|
+
}
|
|
580
|
+
if (message.controlGeneration !== this.generation) return { ...base(), outcome: "stale" };
|
|
581
|
+
if (message.action === "suppressDelivery" && Array.isArray(message.deliveryIds)) {
|
|
582
|
+
for (const ticket of this.tickets.values())
|
|
583
|
+
if (ticket.deliveryId && message.deliveryIds.includes(ticket.deliveryId)) {
|
|
584
|
+
ticket.state = "suppressed";
|
|
585
|
+
this.payloads.delete(ticket.deliveryId);
|
|
586
|
+
}
|
|
587
|
+
this.publish();
|
|
588
|
+
return { ...base(), outcome: "settled" };
|
|
589
|
+
}
|
|
590
|
+
const taskId = typeof message.taskId === "string" ? message.taskId : "";
|
|
591
|
+
const task = this.tasks.get(taskId);
|
|
592
|
+
if (!task) return { ...base(), outcome: "rejected", reason: "Unknown task" };
|
|
593
|
+
if (message.action === "detail")
|
|
594
|
+
return { ...base(), outcome: "settled", detail: (this.hooks.detail(taskId) ?? "").slice(0, 4096) };
|
|
595
|
+
if (message.action !== "cancel") return { ...base(), outcome: "unsupported" };
|
|
596
|
+
task.execution = "cancelling";
|
|
597
|
+
this.publish(task);
|
|
598
|
+
if (!this.spawned.has(taskId)) await this.abandon(taskId);
|
|
599
|
+
try {
|
|
600
|
+
await this.hooks.cancel(taskId);
|
|
601
|
+
} catch (error) {
|
|
602
|
+
return { ...base(), outcome: "blocked_cleanup", reason: String(error).slice(0, 4096) };
|
|
603
|
+
}
|
|
604
|
+
return {
|
|
605
|
+
...base(),
|
|
606
|
+
outcome: task.presence === "settled" ? "settled" : task.presence === "unknown" ? "blocked_cleanup" : "accepted",
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
shutdown(): void {
|
|
611
|
+
this.open = false;
|
|
612
|
+
this.stopped = true;
|
|
613
|
+
this.bindingEpoch++;
|
|
614
|
+
this.resolveDiscovery?.();
|
|
615
|
+
this.hooks.close();
|
|
616
|
+
for (const pending of this.pending.values()) pending.resolve();
|
|
617
|
+
for (const ticket of this.tickets.values())
|
|
618
|
+
if (ticket.state === "pending" || (ticket.state === "failed" && !ticket.deliveryId)) ticket.state = "suppressed";
|
|
619
|
+
this.publish();
|
|
620
|
+
this.unsubscribe?.();
|
|
621
|
+
this.unsubscribe = undefined;
|
|
622
|
+
}
|
|
623
|
+
}
|