@abelxiaoxing/cadence 1.0.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 +41 -0
- package/THIRD_PARTY_NOTICES.md +12 -0
- package/agents/contract-reviewer.md +11 -0
- package/agents/design-explorer.md +18 -0
- package/agents/diagnosis-worker.md +12 -0
- package/agents/implementation-worker.md +18 -0
- package/config/.env.example +14 -0
- package/licenses/pi-subagents-MIT.txt +21 -0
- package/package.json +69 -0
- package/prompts/abel-design.md +181 -0
- package/prompts/abel-diagnose.md +34 -0
- package/prompts/abel-implement.md +132 -0
- package/prompts/abel-init.md +42 -0
- package/skills/_shared/http-client.mjs +89 -0
- package/skills/_shared/load-config.mjs +64 -0
- package/skills/abel-workflow/SKILL.md +82 -0
- package/skills/context7-auto-research/SKILL.md +32 -0
- package/skills/context7-auto-research/context7.mjs +132 -0
- package/skills/git-commit/SKILL.md +37 -0
- package/skills/grok-search/SKILL.md +39 -0
- package/skills/grok-search/grok-search.mjs +414 -0
- package/src/activation.ts +44 -0
- package/src/agent-registry.ts +54 -0
- package/src/child-session.ts +253 -0
- package/src/contracts.ts +375 -0
- package/src/drain.ts +20 -0
- package/src/empty-resource-loader.ts +38 -0
- package/src/file-snapshot.ts +113 -0
- package/src/index.ts +76 -0
- package/src/parent-provider.ts +85 -0
- package/src/patch.ts +113 -0
- package/src/result-store.ts +40 -0
- package/src/runtime.ts +305 -0
- package/src/scheduler.ts +304 -0
- package/src/scoped-tools.ts +282 -0
- package/src/submit-tool.ts +135 -0
- package/src/worker.ts +119 -0
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Activation, type ActivationState } from "./activation.ts";
|
|
3
|
+
import { loadAgentDefinitions } from "./agent-registry.ts";
|
|
4
|
+
import { runChildSession } from "./child-session.ts";
|
|
5
|
+
import {
|
|
6
|
+
ACTIONS,
|
|
7
|
+
type DiffResult,
|
|
8
|
+
LIMITS,
|
|
9
|
+
type RequestEnvelope,
|
|
10
|
+
validateRequestEnvelope,
|
|
11
|
+
} from "./contracts.ts";
|
|
12
|
+
import { drainStage } from "./drain.ts";
|
|
13
|
+
import { type Bound, mergeBounds } from "./file-snapshot.ts";
|
|
14
|
+
import { runtimeFromContext } from "./parent-provider.ts";
|
|
15
|
+
import { applyRetainedPatch } from "./patch.ts";
|
|
16
|
+
import { ResultStore } from "./result-store.ts";
|
|
17
|
+
import {
|
|
18
|
+
contractOf,
|
|
19
|
+
sameContract,
|
|
20
|
+
WorkerRegistry,
|
|
21
|
+
workerIdentity,
|
|
22
|
+
} from "./worker.ts";
|
|
23
|
+
|
|
24
|
+
const SHA256_HEX = /^[0-9a-f]{64}$/;
|
|
25
|
+
|
|
26
|
+
function isSafeBound(snapshot: unknown): snapshot is Bound {
|
|
27
|
+
if (
|
|
28
|
+
typeof snapshot !== "object" ||
|
|
29
|
+
snapshot === null ||
|
|
30
|
+
Array.isArray(snapshot)
|
|
31
|
+
)
|
|
32
|
+
return false;
|
|
33
|
+
for (const [path, value] of Object.entries(
|
|
34
|
+
snapshot as Record<string, unknown>,
|
|
35
|
+
)) {
|
|
36
|
+
if (
|
|
37
|
+
!path ||
|
|
38
|
+
path.startsWith("/") ||
|
|
39
|
+
path === "." ||
|
|
40
|
+
path === ".." ||
|
|
41
|
+
path.includes("/../") ||
|
|
42
|
+
path.endsWith("/..") ||
|
|
43
|
+
path.includes("\\")
|
|
44
|
+
)
|
|
45
|
+
return false;
|
|
46
|
+
if (typeof value !== "object" || value === null) return false;
|
|
47
|
+
const entry = value as Record<string, unknown>;
|
|
48
|
+
if (entry.kind === "file") {
|
|
49
|
+
if (
|
|
50
|
+
typeof entry.sha256 !== "string" ||
|
|
51
|
+
!SHA256_HEX.test(entry.sha256) ||
|
|
52
|
+
typeof entry.bytes !== "number" ||
|
|
53
|
+
!Number.isSafeInteger(entry.bytes) ||
|
|
54
|
+
entry.bytes < 0
|
|
55
|
+
)
|
|
56
|
+
return false;
|
|
57
|
+
} else if (entry.kind === "dir") {
|
|
58
|
+
if (
|
|
59
|
+
typeof entry.manifest !== "string" ||
|
|
60
|
+
!SHA256_HEX.test(entry.manifest)
|
|
61
|
+
)
|
|
62
|
+
return false;
|
|
63
|
+
} else if (entry.kind === "absent") {
|
|
64
|
+
if (entry.absent !== true) return false;
|
|
65
|
+
} else {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export type DispatchResult =
|
|
73
|
+
| {
|
|
74
|
+
ok: true;
|
|
75
|
+
action: string;
|
|
76
|
+
result?: unknown;
|
|
77
|
+
resultId?: string;
|
|
78
|
+
usage?: unknown;
|
|
79
|
+
}
|
|
80
|
+
| { ok: false; notReady?: true; error: string };
|
|
81
|
+
|
|
82
|
+
export interface ApplyResult {
|
|
83
|
+
targets: string[];
|
|
84
|
+
checkExitCode: 0;
|
|
85
|
+
applyExitCode: 0;
|
|
86
|
+
sequence?: number;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export interface RuntimeOptions {
|
|
90
|
+
activation?: Activation;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
type RunContext = Pick<ExtensionContext, "cwd" | "model" | "modelRegistry">;
|
|
94
|
+
|
|
95
|
+
export class Runtime {
|
|
96
|
+
readonly activation: Activation;
|
|
97
|
+
readonly limits = LIMITS;
|
|
98
|
+
readonly results = new ResultStore();
|
|
99
|
+
private applyTail: Promise<void> = Promise.resolve();
|
|
100
|
+
private applySeq = 0;
|
|
101
|
+
private readonly registry = new WorkerRegistry();
|
|
102
|
+
|
|
103
|
+
constructor(opts: RuntimeOptions = {}) {
|
|
104
|
+
this.activation = opts.activation ?? new Activation();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async execute(
|
|
108
|
+
action: string,
|
|
109
|
+
params: { request?: unknown; resultId?: string },
|
|
110
|
+
ctx?: RunContext,
|
|
111
|
+
): Promise<DispatchResult> {
|
|
112
|
+
if (!(ACTIONS as readonly string[]).includes(action)) {
|
|
113
|
+
return { ok: false, error: `unknown action: ${String(action)}` };
|
|
114
|
+
}
|
|
115
|
+
if (!this.activation.isActive()) {
|
|
116
|
+
return { ok: false, notReady: true, error: "dispatcher is not active" };
|
|
117
|
+
}
|
|
118
|
+
if (action === "run") return this.run(params.request, ctx);
|
|
119
|
+
if (action === "apply") {
|
|
120
|
+
if (!ctx || !params.resultId)
|
|
121
|
+
return { ok: false, error: "apply requires context and resultId" };
|
|
122
|
+
return this.enqueueApply(ctx.cwd, params.resultId);
|
|
123
|
+
}
|
|
124
|
+
if (action === "discard") {
|
|
125
|
+
if (!params.resultId)
|
|
126
|
+
return { ok: false, error: "discard requires resultId" };
|
|
127
|
+
return this.results.discard(params.resultId)
|
|
128
|
+
? { ok: true, action }
|
|
129
|
+
: { ok: false, error: "retained result not found" };
|
|
130
|
+
}
|
|
131
|
+
if (action === "cancel") return { ok: true, action };
|
|
132
|
+
drainStage({
|
|
133
|
+
results: this.results,
|
|
134
|
+
registry: this.registry,
|
|
135
|
+
activation: this.activation,
|
|
136
|
+
});
|
|
137
|
+
return { ok: true, action: "finish" };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private enqueueApply(root: string, id: string): Promise<DispatchResult> {
|
|
141
|
+
const run = this.applyTail.then(
|
|
142
|
+
() => applyRetainedPatch({ root, id, store: this.results }),
|
|
143
|
+
() => applyRetainedPatch({ root, id, store: this.results }),
|
|
144
|
+
);
|
|
145
|
+
this.applyTail = run.then(
|
|
146
|
+
() => undefined,
|
|
147
|
+
() => undefined,
|
|
148
|
+
);
|
|
149
|
+
const seq = ++this.applySeq;
|
|
150
|
+
return run.then(
|
|
151
|
+
(result) =>
|
|
152
|
+
result.ok
|
|
153
|
+
? { ok: true, action: "apply", result: { ...result, sequence: seq } }
|
|
154
|
+
: result,
|
|
155
|
+
(error) => ({ ok: false, error: (error as Error).message }),
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private async dispatchChild(
|
|
160
|
+
agent: { role: string; content: string },
|
|
161
|
+
envelope: RequestEnvelope,
|
|
162
|
+
ctx: RunContext,
|
|
163
|
+
): Promise<DispatchResult> {
|
|
164
|
+
let phase: Awaited<ReturnType<typeof runtimeFromContext>>;
|
|
165
|
+
try {
|
|
166
|
+
phase = await runtimeFromContext(ctx);
|
|
167
|
+
} catch (error) {
|
|
168
|
+
return { ok: false, error: (error as Error).message };
|
|
169
|
+
}
|
|
170
|
+
const roots = envelope.roots.map(
|
|
171
|
+
(root) => new URL(root, `file://${ctx.cwd}/`).pathname,
|
|
172
|
+
);
|
|
173
|
+
const systemPrompt = [
|
|
174
|
+
agent.content,
|
|
175
|
+
envelope.objective,
|
|
176
|
+
envelope.context.agents,
|
|
177
|
+
envelope.context.contract,
|
|
178
|
+
].join("\n\n");
|
|
179
|
+
const child = await runChildSession({
|
|
180
|
+
cwd: ctx.cwd,
|
|
181
|
+
modelRuntime: phase.modelRuntime,
|
|
182
|
+
model: phase.model,
|
|
183
|
+
systemPrompt,
|
|
184
|
+
requestId: envelope.id,
|
|
185
|
+
role: envelope.role,
|
|
186
|
+
phase: envelope.phase,
|
|
187
|
+
output: envelope.output as "evidence" | "diff",
|
|
188
|
+
roots,
|
|
189
|
+
timeoutMs: LIMITS.phaseTimeoutMs,
|
|
190
|
+
});
|
|
191
|
+
if (!child.ok) return child;
|
|
192
|
+
if (envelope.output === "evidence") {
|
|
193
|
+
return {
|
|
194
|
+
ok: true,
|
|
195
|
+
action: "run",
|
|
196
|
+
result: child.result,
|
|
197
|
+
usage: child.usage,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
const diff = child.result as DiffResult;
|
|
201
|
+
const resultId = this.results.retain({
|
|
202
|
+
diff: diff.diff,
|
|
203
|
+
writeSet: envelope.declared.write,
|
|
204
|
+
root: ctx.cwd,
|
|
205
|
+
});
|
|
206
|
+
if (isSafeBound(envelope.snapshot)) {
|
|
207
|
+
const retained = this.results.get(resultId);
|
|
208
|
+
if (retained) {
|
|
209
|
+
retained.snapshot = mergeBounds(retained.snapshot, envelope.snapshot);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return {
|
|
213
|
+
ok: true,
|
|
214
|
+
action: "run",
|
|
215
|
+
result: diff,
|
|
216
|
+
resultId,
|
|
217
|
+
usage: child.usage,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
private async run(
|
|
222
|
+
request: unknown,
|
|
223
|
+
ctx?: RunContext,
|
|
224
|
+
): Promise<DispatchResult> {
|
|
225
|
+
if (!ctx) return { ok: false, error: "run requires extension context" };
|
|
226
|
+
const validation = validateRequestEnvelope(request);
|
|
227
|
+
if (!validation.ok) return { ok: false, error: validation.reason };
|
|
228
|
+
const envelope = validation.value;
|
|
229
|
+
const agent = loadAgentDefinitions().find(
|
|
230
|
+
(item) => item.role === envelope.role,
|
|
231
|
+
);
|
|
232
|
+
if (!agent) return { ok: false, error: "package Agent role not found" };
|
|
233
|
+
if (!ctx.model)
|
|
234
|
+
return { ok: false, error: "run requires a model identity" };
|
|
235
|
+
const identity = workerIdentity(ctx.model);
|
|
236
|
+
const contract = contractOf(envelope);
|
|
237
|
+
const existing = this.registry.get(envelope.id);
|
|
238
|
+
if (existing) {
|
|
239
|
+
if (existing.identity !== identity) {
|
|
240
|
+
return {
|
|
241
|
+
ok: false,
|
|
242
|
+
error:
|
|
243
|
+
"blocked: logical Worker pinned provider/model identity differs",
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
if (!sameContract(existing.contract, contract)) {
|
|
247
|
+
return {
|
|
248
|
+
ok: false,
|
|
249
|
+
error:
|
|
250
|
+
"blocked: logical Worker contract changed for a recovery scope",
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
if (existing.redispatchUsed) {
|
|
254
|
+
return {
|
|
255
|
+
ok: false,
|
|
256
|
+
error: "blocked: mechanical redispatch is already exhausted",
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
} else {
|
|
260
|
+
this.registry.pin(contract, identity);
|
|
261
|
+
}
|
|
262
|
+
const pinned = this.registry.get(envelope.id);
|
|
263
|
+
const first = await this.dispatchChild(agent, envelope, ctx);
|
|
264
|
+
if (first.ok) {
|
|
265
|
+
if (pinned) pinned.redispatchUsed = false;
|
|
266
|
+
return first;
|
|
267
|
+
}
|
|
268
|
+
if (pinned?.redispatchUsed) {
|
|
269
|
+
return {
|
|
270
|
+
ok: false,
|
|
271
|
+
error: "blocked: mechanical redispatch failed and is exhausted",
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
if (!pinned) {
|
|
275
|
+
return { ok: false, error: "blocked: logical Worker is not pinned" };
|
|
276
|
+
}
|
|
277
|
+
pinned.redispatchUsed = true;
|
|
278
|
+
const second = await this.dispatchChild(agent, envelope, ctx);
|
|
279
|
+
if (!second.ok) {
|
|
280
|
+
return {
|
|
281
|
+
ok: false,
|
|
282
|
+
error: "blocked: identical mechanical redispatch failed again",
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
return second;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
validateRequest(
|
|
289
|
+
envelope: unknown,
|
|
290
|
+
): { ok: true; value: RequestEnvelope } | { ok: false; reason: string } {
|
|
291
|
+
return validateRequestEnvelope(envelope);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
drain(): void {
|
|
295
|
+
drainStage({
|
|
296
|
+
results: this.results,
|
|
297
|
+
registry: this.registry,
|
|
298
|
+
activation: this.activation,
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
get state(): ActivationState {
|
|
303
|
+
return this.activation.state;
|
|
304
|
+
}
|
|
305
|
+
}
|
package/src/scheduler.ts
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
// Minimal in-memory scheduler for the private orchestration kernel.
|
|
2
|
+
// FIFO DAG-ready admission with one global active limit, declared-set
|
|
3
|
+
// declared serialization, batch cancellation, and at most one mechanical redispatch.
|
|
4
|
+
import { LIMITS, type RequestEnvelope } from "./contracts.ts";
|
|
5
|
+
|
|
6
|
+
export type TerminalStatus = "succeeded" | "failed" | "cancelled";
|
|
7
|
+
|
|
8
|
+
export interface ScheduledRequest {
|
|
9
|
+
request: RequestEnvelope;
|
|
10
|
+
prerequisites: string[];
|
|
11
|
+
mechanicalRedispatch?: boolean;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ScheduledOutcome<T = unknown> {
|
|
15
|
+
id: string;
|
|
16
|
+
status: TerminalStatus;
|
|
17
|
+
attempts: number;
|
|
18
|
+
value?: T;
|
|
19
|
+
error?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface BatchHandle<T = unknown> {
|
|
23
|
+
batchId: string;
|
|
24
|
+
result(id: string): Promise<ScheduledOutcome<T>>;
|
|
25
|
+
done: Promise<ScheduledOutcome<T>[]>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface SchedulerOptions<T = unknown> {
|
|
29
|
+
limit: number;
|
|
30
|
+
execute(
|
|
31
|
+
request: RequestEnvelope,
|
|
32
|
+
signal: AbortSignal,
|
|
33
|
+
attempt: number,
|
|
34
|
+
): Promise<T>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
type EntryState = "queued" | "running" | TerminalStatus;
|
|
38
|
+
|
|
39
|
+
interface Entry<T> {
|
|
40
|
+
request: RequestEnvelope;
|
|
41
|
+
prerequisites: string[];
|
|
42
|
+
mechanicalRedispatch: boolean;
|
|
43
|
+
state: EntryState;
|
|
44
|
+
attempts: number;
|
|
45
|
+
cancelRequested: boolean;
|
|
46
|
+
controller: AbortController | null;
|
|
47
|
+
resolve(outcome: ScheduledOutcome<T>): void;
|
|
48
|
+
promise: Promise<ScheduledOutcome<T>>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function intersects(left: string[], right: string[]): boolean {
|
|
52
|
+
return left.some((path) => right.includes(path));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function conflictEdgeMatches(
|
|
56
|
+
conflicts: string[],
|
|
57
|
+
other: RequestEnvelope,
|
|
58
|
+
): boolean {
|
|
59
|
+
return conflicts.some(
|
|
60
|
+
(edge) =>
|
|
61
|
+
edge === other.id ||
|
|
62
|
+
other.declared.read.includes(edge) ||
|
|
63
|
+
other.declared.write.includes(edge),
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** True when two declared envelopes cannot be active at the same time. */
|
|
68
|
+
export function declarationsConflict(
|
|
69
|
+
left: RequestEnvelope,
|
|
70
|
+
right: RequestEnvelope,
|
|
71
|
+
): boolean {
|
|
72
|
+
const a = left.declared;
|
|
73
|
+
const b = right.declared;
|
|
74
|
+
if (intersects(a.write, b.write)) return true;
|
|
75
|
+
if (intersects(a.write, b.read) || intersects(a.read, b.write)) return true;
|
|
76
|
+
if (conflictEdgeMatches(a.conflicts, right)) return true;
|
|
77
|
+
if (conflictEdgeMatches(b.conflicts, left)) return true;
|
|
78
|
+
if (intersects(a.resources, b.resources)) return true;
|
|
79
|
+
return (
|
|
80
|
+
a.verificationLock !== undefined &&
|
|
81
|
+
a.verificationLock === b.verificationLock
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function errorMessage(error: unknown): string {
|
|
86
|
+
return error instanceof Error ? error.message : String(error);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
class Batch<T> {
|
|
90
|
+
readonly entries: Entry<T>[] = [];
|
|
91
|
+
readonly byId = new Map<string, Entry<T>>();
|
|
92
|
+
|
|
93
|
+
constructor(
|
|
94
|
+
readonly batchId: string,
|
|
95
|
+
requests: ScheduledRequest[],
|
|
96
|
+
) {
|
|
97
|
+
for (const scheduled of requests) {
|
|
98
|
+
let resolve: (outcome: ScheduledOutcome<T>) => void = () => {};
|
|
99
|
+
const promise = new Promise<ScheduledOutcome<T>>((accept) => {
|
|
100
|
+
resolve = accept;
|
|
101
|
+
});
|
|
102
|
+
const entry: Entry<T> = {
|
|
103
|
+
request: scheduled.request,
|
|
104
|
+
prerequisites: [...scheduled.prerequisites],
|
|
105
|
+
mechanicalRedispatch: scheduled.mechanicalRedispatch === true,
|
|
106
|
+
state: "queued",
|
|
107
|
+
attempts: 0,
|
|
108
|
+
cancelRequested: false,
|
|
109
|
+
controller: null,
|
|
110
|
+
resolve,
|
|
111
|
+
promise,
|
|
112
|
+
};
|
|
113
|
+
this.entries.push(entry);
|
|
114
|
+
this.byId.set(entry.request.id, entry);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export class Scheduler<T> {
|
|
120
|
+
private readonly limit: number;
|
|
121
|
+
private readonly execute: SchedulerOptions<T>["execute"];
|
|
122
|
+
private readonly batches: Batch<T>[] = [];
|
|
123
|
+
private readonly active = new Set<Entry<T>>();
|
|
124
|
+
|
|
125
|
+
constructor(options: SchedulerOptions<T>) {
|
|
126
|
+
this.limit = Math.max(
|
|
127
|
+
1,
|
|
128
|
+
Math.min(options.limit, LIMITS.maxActiveChildSessions),
|
|
129
|
+
);
|
|
130
|
+
this.execute = options.execute;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
schedule(batchId: string, requests: ScheduledRequest[]): BatchHandle<T> {
|
|
134
|
+
if (requests.length > LIMITS.maxRequestsPerBatch) {
|
|
135
|
+
throw new Error(
|
|
136
|
+
`batch ${batchId} exceeds ${LIMITS.maxRequestsPerBatch} requests`,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
const batch = new Batch<T>(batchId, requests);
|
|
140
|
+
this.batches.push(batch);
|
|
141
|
+
for (const entry of batch.entries) {
|
|
142
|
+
const missing = entry.prerequisites.find((id) => !batch.byId.has(id));
|
|
143
|
+
if (missing !== undefined) {
|
|
144
|
+
this.settle(batch, entry, {
|
|
145
|
+
id: entry.request.id,
|
|
146
|
+
status: "cancelled",
|
|
147
|
+
attempts: 0,
|
|
148
|
+
error: `unknown prerequisite: ${missing}`,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
this.pump();
|
|
153
|
+
return {
|
|
154
|
+
batchId,
|
|
155
|
+
result: (id: string) => {
|
|
156
|
+
const entry = batch.byId.get(id);
|
|
157
|
+
if (!entry) throw new Error(`unknown scheduled request: ${id}`);
|
|
158
|
+
return entry.promise;
|
|
159
|
+
},
|
|
160
|
+
done: Promise.all(batch.entries.map((entry) => entry.promise)),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
cancel(batchId: string): void {
|
|
165
|
+
const batch = this.batches.find(
|
|
166
|
+
(candidate) => candidate.batchId === batchId,
|
|
167
|
+
);
|
|
168
|
+
if (!batch) return;
|
|
169
|
+
for (const entry of batch.entries) {
|
|
170
|
+
if (entry.state === "queued") {
|
|
171
|
+
entry.cancelRequested = true;
|
|
172
|
+
this.settle(batch, entry, this.cancelOutcome(entry));
|
|
173
|
+
} else if (entry.state === "running") {
|
|
174
|
+
entry.cancelRequested = true;
|
|
175
|
+
entry.controller?.abort();
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
this.pump();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private pump(): void {
|
|
182
|
+
for (const batch of this.batches) {
|
|
183
|
+
for (const entry of batch.entries) {
|
|
184
|
+
if (this.active.size >= this.limit) return;
|
|
185
|
+
if (entry.state !== "queued") continue;
|
|
186
|
+
if (!this.prerequisitesSucceeded(batch, entry)) continue;
|
|
187
|
+
if (this.hasActiveConflict(entry)) continue;
|
|
188
|
+
this.start(batch, entry);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
private prerequisitesSucceeded(batch: Batch<T>, entry: Entry<T>): boolean {
|
|
194
|
+
return entry.prerequisites.every(
|
|
195
|
+
(id) => batch.byId.get(id)?.state === "succeeded",
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
private hasActiveConflict(entry: Entry<T>): boolean {
|
|
200
|
+
for (const activeEntry of this.active) {
|
|
201
|
+
if (declarationsConflict(entry.request, activeEntry.request)) {
|
|
202
|
+
return true;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
private start(batch: Batch<T>, entry: Entry<T>): void {
|
|
209
|
+
// Guarded start: a cancelled or settled queued closure never runs later.
|
|
210
|
+
if (entry.state !== "queued") return;
|
|
211
|
+
entry.state = "running";
|
|
212
|
+
entry.attempts += 1;
|
|
213
|
+
entry.controller = new AbortController();
|
|
214
|
+
this.active.add(entry);
|
|
215
|
+
const attempt = entry.attempts;
|
|
216
|
+
this.execute(entry.request, entry.controller.signal, attempt).then(
|
|
217
|
+
(value) => {
|
|
218
|
+
if (entry.cancelRequested) {
|
|
219
|
+
this.settleCancelled(batch, entry);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
this.settle(batch, entry, {
|
|
223
|
+
id: entry.request.id,
|
|
224
|
+
status: "succeeded",
|
|
225
|
+
attempts: entry.attempts,
|
|
226
|
+
value,
|
|
227
|
+
});
|
|
228
|
+
},
|
|
229
|
+
(error) => {
|
|
230
|
+
if (entry.cancelRequested) {
|
|
231
|
+
this.settleCancelled(batch, entry);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
if (entry.mechanicalRedispatch && attempt === 1) {
|
|
235
|
+
this.redispatch(batch, entry);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
this.settle(batch, entry, {
|
|
239
|
+
id: entry.request.id,
|
|
240
|
+
status: "failed",
|
|
241
|
+
attempts: entry.attempts,
|
|
242
|
+
error: errorMessage(error),
|
|
243
|
+
});
|
|
244
|
+
},
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
private redispatch(batch: Batch<T>, entry: Entry<T>): void {
|
|
249
|
+
// One identical mechanical redispatch in the retained active slot; the
|
|
250
|
+
// attempt becomes 2 and a further failure settles, so no third attempt.
|
|
251
|
+
entry.state = "queued";
|
|
252
|
+
entry.controller = null;
|
|
253
|
+
this.start(batch, entry);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
private settleCancelled(batch: Batch<T>, entry: Entry<T>): void {
|
|
257
|
+
this.settle(batch, entry, this.cancelOutcome(entry));
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
private cancelOutcome(entry: Entry<T>): ScheduledOutcome<T> {
|
|
261
|
+
return {
|
|
262
|
+
id: entry.request.id,
|
|
263
|
+
status: "cancelled",
|
|
264
|
+
attempts: entry.attempts,
|
|
265
|
+
error: "batch cancelled",
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
private settle(
|
|
270
|
+
batch: Batch<T>,
|
|
271
|
+
entry: Entry<T>,
|
|
272
|
+
outcome: ScheduledOutcome<T>,
|
|
273
|
+
): void {
|
|
274
|
+
// Every scheduled request reaches exactly one terminal state.
|
|
275
|
+
if (entry.state !== "queued" && entry.state !== "running") return;
|
|
276
|
+
entry.state = outcome.status;
|
|
277
|
+
this.active.delete(entry);
|
|
278
|
+
entry.controller = null;
|
|
279
|
+
entry.resolve(outcome);
|
|
280
|
+
if (outcome.status !== "succeeded") {
|
|
281
|
+
this.blockDependents(batch, entry.request.id, outcome.status);
|
|
282
|
+
}
|
|
283
|
+
this.pump();
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
private blockDependents(
|
|
287
|
+
batch: Batch<T>,
|
|
288
|
+
prerequisiteId: string,
|
|
289
|
+
status: TerminalStatus,
|
|
290
|
+
): void {
|
|
291
|
+
// A dependent can never start after a prerequisite failure or cancel;
|
|
292
|
+
// independent siblings keep their own outcomes.
|
|
293
|
+
for (const dependent of batch.entries) {
|
|
294
|
+
if (dependent.state !== "queued") continue;
|
|
295
|
+
if (!dependent.prerequisites.includes(prerequisiteId)) continue;
|
|
296
|
+
this.settle(batch, dependent, {
|
|
297
|
+
id: dependent.request.id,
|
|
298
|
+
status: "cancelled",
|
|
299
|
+
attempts: dependent.attempts,
|
|
300
|
+
error: `blocked: prerequisite ${prerequisiteId} ${status}`,
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|