@abelxiaoxing/cadence 1.0.0 → 1.0.1

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/src/runtime.ts CHANGED
@@ -14,6 +14,7 @@ import { type Bound, mergeBounds } from "./file-snapshot.ts";
14
14
  import { runtimeFromContext } from "./parent-provider.ts";
15
15
  import { applyRetainedPatch } from "./patch.ts";
16
16
  import { ResultStore } from "./result-store.ts";
17
+ import { Scheduler } from "./scheduler.ts";
17
18
  import {
18
19
  contractOf,
19
20
  sameContract,
@@ -23,6 +24,51 @@ import {
23
24
 
24
25
  const SHA256_HEX = /^[0-9a-f]{64}$/;
25
26
 
27
+ function cancellationError(signal: AbortSignal): { ok: false; error: string } {
28
+ const reason = signal.reason;
29
+ return {
30
+ ok: false,
31
+ error:
32
+ reason instanceof Error
33
+ ? reason.message
34
+ : typeof reason === "string"
35
+ ? reason
36
+ : "child phase cancelled",
37
+ };
38
+ }
39
+
40
+ export type RunFailureKind = "failed" | "cancelled" | "timed-out";
41
+ export type RuntimeFailureReason =
42
+ | "subagent failed"
43
+ | "subagent cancelled"
44
+ | "phase timed out";
45
+ export type RuntimeActivityState =
46
+ | "queued"
47
+ | "running"
48
+ | "completed"
49
+ | "failed"
50
+ | "cancelled"
51
+ | "timed-out";
52
+
53
+ export interface RuntimeActivityEvent {
54
+ state: RuntimeActivityState;
55
+ requestId: string;
56
+ role: string;
57
+ phase: string;
58
+ objective: string;
59
+ sequence: number;
60
+ failureReason?: RuntimeFailureReason;
61
+ }
62
+
63
+ export type RuntimeActivityObserver = (
64
+ event: RuntimeActivityEvent,
65
+ ) => void | Promise<void>;
66
+
67
+ interface ScheduledRunResult {
68
+ dispatch: DispatchResult;
69
+ failureKind?: RunFailureKind;
70
+ }
71
+
26
72
  function isSafeBound(snapshot: unknown): snapshot is Bound {
27
73
  if (
28
74
  typeof snapshot !== "object" ||
@@ -92,22 +138,53 @@ export interface RuntimeOptions {
92
138
 
93
139
  type RunContext = Pick<ExtensionContext, "cwd" | "model" | "modelRegistry">;
94
140
 
141
+ interface StoredRunContext {
142
+ ctx: RunContext;
143
+ observer?: RuntimeActivityObserver;
144
+ sequence: number;
145
+ runningEmitted: boolean;
146
+ terminalEmitted: boolean;
147
+ }
148
+
95
149
  export class Runtime {
96
150
  readonly activation: Activation;
97
151
  readonly limits = LIMITS;
98
152
  readonly results = new ResultStore();
99
153
  private applyTail: Promise<void> = Promise.resolve();
100
154
  private applySeq = 0;
155
+ private batchSeq = 0;
101
156
  private readonly registry = new WorkerRegistry();
157
+ private readonly runContexts = new WeakMap<
158
+ RequestEnvelope,
159
+ StoredRunContext
160
+ >();
161
+ private readonly scheduler: Scheduler<ScheduledRunResult>;
102
162
 
103
163
  constructor(opts: RuntimeOptions = {}) {
104
164
  this.activation = opts.activation ?? new Activation();
165
+ this.scheduler = new Scheduler({
166
+ limit: LIMITS.maxActiveChildSessions,
167
+ execute: async (request, signal) => {
168
+ const context = this.runContexts.get(request);
169
+ if (!context) throw new Error("scheduled run context is unavailable");
170
+ this.notify(request, context, "running");
171
+ const result = await this.runScheduled(request, context.ctx, signal);
172
+ return {
173
+ dispatch: result,
174
+ ...(result.failureKind === undefined
175
+ ? {}
176
+ : { failureKind: result.failureKind }),
177
+ };
178
+ },
179
+ });
105
180
  }
106
181
 
107
182
  async execute(
108
183
  action: string,
109
184
  params: { request?: unknown; resultId?: string },
110
185
  ctx?: RunContext,
186
+ signal?: AbortSignal,
187
+ observer?: RuntimeActivityObserver,
111
188
  ): Promise<DispatchResult> {
112
189
  if (!(ACTIONS as readonly string[]).includes(action)) {
113
190
  return { ok: false, error: `unknown action: ${String(action)}` };
@@ -115,7 +192,8 @@ export class Runtime {
115
192
  if (!this.activation.isActive()) {
116
193
  return { ok: false, notReady: true, error: "dispatcher is not active" };
117
194
  }
118
- if (action === "run") return this.run(params.request, ctx);
195
+ if (action === "run")
196
+ return this.run(params.request, ctx, signal, observer);
119
197
  if (action === "apply") {
120
198
  if (!ctx || !params.resultId)
121
199
  return { ok: false, error: "apply requires context and resultId" };
@@ -128,12 +206,11 @@ export class Runtime {
128
206
  ? { ok: true, action }
129
207
  : { ok: false, error: "retained result not found" };
130
208
  }
131
- if (action === "cancel") return { ok: true, action };
132
- drainStage({
133
- results: this.results,
134
- registry: this.registry,
135
- activation: this.activation,
136
- });
209
+ if (action === "cancel") {
210
+ await this.scheduler.cancelAll();
211
+ return { ok: true, action };
212
+ }
213
+ await this.drain();
137
214
  return { ok: true, action: "finish" };
138
215
  }
139
216
 
@@ -160,13 +237,30 @@ export class Runtime {
160
237
  agent: { role: string; content: string },
161
238
  envelope: RequestEnvelope,
162
239
  ctx: RunContext,
163
- ): Promise<DispatchResult> {
240
+ signal: AbortSignal,
241
+ ): Promise<DispatchResult & { failureKind?: RunFailureKind }> {
242
+ if (signal.aborted)
243
+ return {
244
+ ...cancellationError(signal),
245
+ failureKind: "cancelled",
246
+ };
164
247
  let phase: Awaited<ReturnType<typeof runtimeFromContext>>;
165
248
  try {
166
- phase = await runtimeFromContext(ctx);
249
+ phase = await runtimeFromContext(ctx, signal);
167
250
  } catch (error) {
168
- return { ok: false, error: (error as Error).message };
251
+ return {
252
+ ok: false,
253
+ error: signal.aborted
254
+ ? cancellationError(signal).error
255
+ : (error as Error).message,
256
+ failureKind: signal.aborted ? "cancelled" : "failed",
257
+ };
169
258
  }
259
+ if (signal.aborted)
260
+ return {
261
+ ...cancellationError(signal),
262
+ failureKind: "cancelled",
263
+ };
170
264
  const roots = envelope.roots.map(
171
265
  (root) => new URL(root, `file://${ctx.cwd}/`).pathname,
172
266
  );
@@ -187,8 +281,14 @@ export class Runtime {
187
281
  output: envelope.output as "evidence" | "diff",
188
282
  roots,
189
283
  timeoutMs: LIMITS.phaseTimeoutMs,
284
+ signal,
190
285
  });
191
- if (!child.ok) return child;
286
+ if (signal.aborted)
287
+ return {
288
+ ...cancellationError(signal),
289
+ failureKind: "cancelled",
290
+ };
291
+ if (!child.ok) return { ...child, failureKind: child.failureKind };
192
292
  if (envelope.output === "evidence") {
193
293
  return {
194
294
  ok: true,
@@ -221,17 +321,140 @@ export class Runtime {
221
321
  private async run(
222
322
  request: unknown,
223
323
  ctx?: RunContext,
324
+ signal?: AbortSignal,
325
+ observer?: RuntimeActivityObserver,
224
326
  ): Promise<DispatchResult> {
225
327
  if (!ctx) return { ok: false, error: "run requires extension context" };
328
+ if (signal?.aborted) return cancellationError(signal);
226
329
  const validation = validateRequestEnvelope(request);
227
330
  if (!validation.ok) return { ok: false, error: validation.reason };
228
- const envelope = validation.value;
331
+ const envelope: RequestEnvelope = {
332
+ ...validation.value,
333
+ roots: [...validation.value.roots],
334
+ context: { ...validation.value.context },
335
+ declared: {
336
+ ...validation.value.declared,
337
+ read: [...validation.value.declared.read],
338
+ write: [...validation.value.declared.write],
339
+ conflicts: [...validation.value.declared.conflicts],
340
+ resources: [...validation.value.declared.resources],
341
+ },
342
+ };
343
+ const context: StoredRunContext = {
344
+ ctx,
345
+ observer,
346
+ sequence: ++this.batchSeq,
347
+ runningEmitted: false,
348
+ terminalEmitted: false,
349
+ };
350
+ this.runContexts.set(envelope, context);
351
+ this.notify(envelope, context, "queued");
352
+ const batchId = `runtime-${context.sequence}`;
353
+ const batch = this.scheduler.schedule(batchId, [
354
+ { request: envelope, prerequisites: [] },
355
+ ]);
356
+ const cancelBatch = () =>
357
+ this.scheduler.cancel(
358
+ batchId,
359
+ signal?.reason ?? new Error("tool call cancelled"),
360
+ );
361
+ if (signal?.aborted) cancelBatch();
362
+ else signal?.addEventListener("abort", cancelBatch, { once: true });
363
+ try {
364
+ const outcome = await batch.result(envelope.id);
365
+ if (outcome.status === "succeeded" && outcome.value) {
366
+ const internalResult = outcome.value.dispatch as DispatchResult & {
367
+ failureKind?: RunFailureKind;
368
+ };
369
+ const { failureKind, ...publicResult } = internalResult;
370
+ if (publicResult.ok) {
371
+ this.notify(envelope, context, "completed");
372
+ return publicResult;
373
+ }
374
+ const state: RuntimeActivityState = failureKind ?? "failed";
375
+ this.notify(envelope, context, state);
376
+ return publicResult;
377
+ }
378
+ const state: RuntimeActivityState =
379
+ outcome.status === "cancelled" ? "cancelled" : "failed";
380
+ const terminal = {
381
+ ok: false as const,
382
+ error: outcome.error ?? `scheduled run ${outcome.status}`,
383
+ };
384
+ this.notify(envelope, context, state);
385
+ return terminal;
386
+ } finally {
387
+ signal?.removeEventListener("abort", cancelBatch);
388
+ this.runContexts.delete(envelope);
389
+ }
390
+ }
391
+
392
+ private notify(
393
+ envelope: RequestEnvelope,
394
+ context: StoredRunContext,
395
+ state: RuntimeActivityState,
396
+ ): void {
397
+ if (!context.observer || context.terminalEmitted) return;
398
+ if (state === "running") {
399
+ if (context.runningEmitted) return;
400
+ context.runningEmitted = true;
401
+ }
402
+ if (
403
+ state === "completed" ||
404
+ state === "failed" ||
405
+ state === "cancelled" ||
406
+ state === "timed-out"
407
+ ) {
408
+ context.terminalEmitted = true;
409
+ }
410
+ const event: RuntimeActivityEvent = {
411
+ state,
412
+ requestId: envelope.id,
413
+ role: envelope.role,
414
+ phase: envelope.phase,
415
+ objective: envelope.objective,
416
+ sequence: context.sequence,
417
+ ...(state === "cancelled"
418
+ ? { failureReason: "subagent cancelled" as const }
419
+ : state === "timed-out"
420
+ ? { failureReason: "phase timed out" as const }
421
+ : state === "failed"
422
+ ? { failureReason: "subagent failed" as const }
423
+ : {}),
424
+ };
425
+ try {
426
+ const result = context.observer(event);
427
+ if (result instanceof Promise) void result.catch(() => undefined);
428
+ } catch {
429
+ // Presentation observers cannot affect orchestration.
430
+ }
431
+ }
432
+
433
+ private async runScheduled(
434
+ envelope: RequestEnvelope,
435
+ ctx: RunContext,
436
+ signal: AbortSignal,
437
+ ): Promise<DispatchResult & { failureKind?: RunFailureKind }> {
438
+ if (signal.aborted)
439
+ return {
440
+ ...cancellationError(signal),
441
+ failureKind: "cancelled",
442
+ };
229
443
  const agent = loadAgentDefinitions().find(
230
444
  (item) => item.role === envelope.role,
231
445
  );
232
- if (!agent) return { ok: false, error: "package Agent role not found" };
446
+ if (!agent)
447
+ return {
448
+ ok: false,
449
+ error: "package Agent role not found",
450
+ failureKind: "failed",
451
+ };
233
452
  if (!ctx.model)
234
- return { ok: false, error: "run requires a model identity" };
453
+ return {
454
+ ok: false,
455
+ error: "run requires a model identity",
456
+ failureKind: "failed",
457
+ };
235
458
  const identity = workerIdentity(ctx.model);
236
459
  const contract = contractOf(envelope);
237
460
  const existing = this.registry.get(envelope.id);
@@ -241,6 +464,7 @@ export class Runtime {
241
464
  ok: false,
242
465
  error:
243
466
  "blocked: logical Worker pinned provider/model identity differs",
467
+ failureKind: "failed",
244
468
  };
245
469
  }
246
470
  if (!sameContract(existing.contract, contract)) {
@@ -248,19 +472,26 @@ export class Runtime {
248
472
  ok: false,
249
473
  error:
250
474
  "blocked: logical Worker contract changed for a recovery scope",
475
+ failureKind: "failed",
251
476
  };
252
477
  }
253
478
  if (existing.redispatchUsed) {
254
479
  return {
255
480
  ok: false,
256
481
  error: "blocked: mechanical redispatch is already exhausted",
482
+ failureKind: "failed",
257
483
  };
258
484
  }
259
485
  } else {
260
486
  this.registry.pin(contract, identity);
261
487
  }
262
488
  const pinned = this.registry.get(envelope.id);
263
- const first = await this.dispatchChild(agent, envelope, ctx);
489
+ const first = await this.dispatchChild(agent, envelope, ctx, signal);
490
+ if (signal.aborted)
491
+ return {
492
+ ...cancellationError(signal),
493
+ failureKind: "cancelled",
494
+ };
264
495
  if (first.ok) {
265
496
  if (pinned) pinned.redispatchUsed = false;
266
497
  return first;
@@ -269,17 +500,28 @@ export class Runtime {
269
500
  return {
270
501
  ok: false,
271
502
  error: "blocked: mechanical redispatch failed and is exhausted",
503
+ failureKind: first.failureKind ?? "failed",
272
504
  };
273
505
  }
274
506
  if (!pinned) {
275
- return { ok: false, error: "blocked: logical Worker is not pinned" };
507
+ return {
508
+ ok: false,
509
+ error: "blocked: logical Worker is not pinned",
510
+ failureKind: "failed",
511
+ };
276
512
  }
277
513
  pinned.redispatchUsed = true;
278
- const second = await this.dispatchChild(agent, envelope, ctx);
514
+ const second = await this.dispatchChild(agent, envelope, ctx, signal);
515
+ if (signal.aborted)
516
+ return {
517
+ ...cancellationError(signal),
518
+ failureKind: "cancelled",
519
+ };
279
520
  if (!second.ok) {
280
521
  return {
281
522
  ok: false,
282
523
  error: "blocked: identical mechanical redispatch failed again",
524
+ failureKind: second.failureKind ?? first.failureKind ?? "failed",
283
525
  };
284
526
  }
285
527
  return second;
@@ -291,12 +533,14 @@ export class Runtime {
291
533
  return validateRequestEnvelope(envelope);
292
534
  }
293
535
 
294
- drain(): void {
536
+ async drain(): Promise<void> {
537
+ const settled = this.scheduler.cancelAll();
295
538
  drainStage({
296
539
  results: this.results,
297
540
  registry: this.registry,
298
541
  activation: this.activation,
299
542
  });
543
+ await settled;
300
544
  }
301
545
 
302
546
  get state(): ActivationState {
package/src/scheduler.ts CHANGED
@@ -43,6 +43,7 @@ interface Entry<T> {
43
43
  state: EntryState;
44
44
  attempts: number;
45
45
  cancelRequested: boolean;
46
+ cancelReason: unknown;
46
47
  controller: AbortController | null;
47
48
  resolve(outcome: ScheduledOutcome<T>): void;
48
49
  promise: Promise<ScheduledOutcome<T>>;
@@ -106,6 +107,7 @@ class Batch<T> {
106
107
  state: "queued",
107
108
  attempts: 0,
108
109
  cancelRequested: false,
110
+ cancelReason: undefined,
109
111
  controller: null,
110
112
  resolve,
111
113
  promise,
@@ -150,6 +152,8 @@ export class Scheduler<T> {
150
152
  }
151
153
  }
152
154
  this.pump();
155
+ const done = Promise.all(batch.entries.map((entry) => entry.promise));
156
+ void done.then(() => this.removeBatch(batch));
153
157
  return {
154
158
  batchId,
155
159
  result: (id: string) => {
@@ -157,22 +161,54 @@ export class Scheduler<T> {
157
161
  if (!entry) throw new Error(`unknown scheduled request: ${id}`);
158
162
  return entry.promise;
159
163
  },
160
- done: Promise.all(batch.entries.map((entry) => entry.promise)),
164
+ done,
161
165
  };
162
166
  }
163
167
 
164
- cancel(batchId: string): void {
168
+ private removeBatch(batch: Batch<T>): void {
169
+ const index = this.batches.indexOf(batch);
170
+ if (index >= 0) this.batches.splice(index, 1);
171
+ }
172
+
173
+ cancel(
174
+ batchId: string,
175
+ reason: unknown = new Error("batch cancelled"),
176
+ ): void {
165
177
  const batch = this.batches.find(
166
178
  (candidate) => candidate.batchId === batchId,
167
179
  );
168
- if (!batch) return;
180
+ if (batch) this.cancelBatch(batch, reason);
181
+ }
182
+
183
+ async cancelAll(
184
+ reason: unknown = new Error("batch cancelled"),
185
+ ): Promise<void> {
186
+ const batches = [...this.batches];
187
+ for (const batch of batches) this.requestCancellation(batch, reason);
188
+ for (const batch of batches) this.settleQueuedCancellation(batch);
189
+ await Promise.all(
190
+ batches.flatMap((batch) => batch.entries.map((entry) => entry.promise)),
191
+ );
192
+ }
193
+
194
+ private cancelBatch(batch: Batch<T>, reason: unknown): void {
195
+ this.requestCancellation(batch, reason);
196
+ this.settleQueuedCancellation(batch);
197
+ }
198
+
199
+ private requestCancellation(batch: Batch<T>, reason: unknown): void {
200
+ for (const entry of batch.entries) {
201
+ if (entry.state !== "queued" && entry.state !== "running") continue;
202
+ if (!entry.cancelRequested) entry.cancelReason = reason;
203
+ entry.cancelRequested = true;
204
+ if (entry.state === "running") entry.controller?.abort(reason);
205
+ }
206
+ }
207
+
208
+ private settleQueuedCancellation(batch: Batch<T>): void {
169
209
  for (const entry of batch.entries) {
170
210
  if (entry.state === "queued") {
171
- entry.cancelRequested = true;
172
211
  this.settle(batch, entry, this.cancelOutcome(entry));
173
- } else if (entry.state === "running") {
174
- entry.cancelRequested = true;
175
- entry.controller?.abort();
176
212
  }
177
213
  }
178
214
  this.pump();
@@ -182,7 +218,7 @@ export class Scheduler<T> {
182
218
  for (const batch of this.batches) {
183
219
  for (const entry of batch.entries) {
184
220
  if (this.active.size >= this.limit) return;
185
- if (entry.state !== "queued") continue;
221
+ if (entry.state !== "queued" || entry.cancelRequested) continue;
186
222
  if (!this.prerequisitesSucceeded(batch, entry)) continue;
187
223
  if (this.hasActiveConflict(entry)) continue;
188
224
  this.start(batch, entry);
@@ -207,7 +243,7 @@ export class Scheduler<T> {
207
243
 
208
244
  private start(batch: Batch<T>, entry: Entry<T>): void {
209
245
  // Guarded start: a cancelled or settled queued closure never runs later.
210
- if (entry.state !== "queued") return;
246
+ if (entry.state !== "queued" || entry.cancelRequested) return;
211
247
  entry.state = "running";
212
248
  entry.attempts += 1;
213
249
  entry.controller = new AbortController();
@@ -262,7 +298,7 @@ export class Scheduler<T> {
262
298
  id: entry.request.id,
263
299
  status: "cancelled",
264
300
  attempts: entry.attempts,
265
- error: "batch cancelled",
301
+ error: errorMessage(entry.cancelReason ?? new Error("batch cancelled")),
266
302
  };
267
303
  }
268
304