@bermudi/pi-delegate 0.1.10 → 0.1.11

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/delegate.ts CHANGED
@@ -5,7 +5,6 @@ export type {
5
5
  SessionAction,
6
6
  WorkspaceMode,
7
7
  TicketAction,
8
- DelegateAction,
9
8
  DelegateArguments,
10
9
  TaskDef,
11
10
  AsyncTicket,
@@ -72,9 +71,10 @@ export {
72
71
  notifyWaiters,
73
72
  deliverTicketResults,
74
73
  resolveFinalTicketStatus,
74
+ settleTicket,
75
75
  formatCompletedTicket,
76
76
  } from "./tickets.ts";
77
- export type { TicketDelivery } from "./tickets.ts";
77
+ export type { TicketDelivery, SettleTicketOptions } from "./tickets.ts";
78
78
  export {
79
79
  recordTreeNavigation,
80
80
  getCurrentLeafId,
@@ -98,6 +98,7 @@ export {
98
98
  export type { ActiveTicketSummary } from "./status.ts";
99
99
  export { getHostDeps, invalidateHostDepsCache } from "./host.ts";
100
100
  export type { HostDeps, HostDepsOptions } from "./host.ts";
101
+ export { registerProviderExtensionNotifier } from "./provider-extensions.ts";
101
102
  export {
102
103
  aggregateTaskResults,
103
104
  emptyUsage,
package/dispatch.ts CHANGED
@@ -6,7 +6,7 @@ import {
6
6
  deliverTicketResults,
7
7
  sweepTickets,
8
8
  resolveFinalTicketStatus,
9
- syncTicketBusyIndex,
9
+ settleTicket,
10
10
  notifyWaiters,
11
11
  } from "./tickets.ts";
12
12
  import { getConcurrencyLimit, getMaxAsyncTickets } from "./config.ts";
@@ -319,6 +319,16 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
319
319
  // Worker must store the TaskResult back into ticket.results, since
320
320
  // formatCompletedTicket/handlePoll read from there. Without the write,
321
321
  // completed async tasks would be reported as PENDING.
322
+ //
323
+ // Worker settlement is complete before these live-runtime observers run.
324
+ // In particular, result delivery is allowed to fail without re-entering the
325
+ // worker completion path; the terminal ticket remains available to poll.
326
+ const finishLiveSettlement = (t: AsyncTicket): void => {
327
+ syncDelegateStatus();
328
+ settleAsyncCall(t, callSpan);
329
+ finishTicketDelivery(pi, t);
330
+ };
331
+
322
332
  const completion = mapConcurrentByModel(
323
333
  resolved,
324
334
  (t) => getModelKey(t.model),
@@ -346,21 +356,16 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
346
356
  // NOT be marked "done" — that would mask incomplete work as
347
357
  // complete. resolveFinalTicketStatus returns "failed" for that
348
358
  // case and for any case with a failed task.
349
- if (ticket.status === "running") {
350
- ticket.status = resolveFinalTicketStatus(ticket);
351
- ticket.completedAt = Date.now();
352
- syncTicketBusyIndex(ticket);
353
- } else if (ticket.status === "cancelling") {
354
- // Cancellation was requested while tasks were still settling. The
355
- // per-task results record what actually happened; the ticket state
356
- // reports that the batch was aborted by the caller.
357
- ticket.status = "cancelled";
358
- ticket.completedAt = Date.now();
359
- syncTicketBusyIndex(ticket);
360
- }
361
- syncDelegateStatus();
362
- settleAsyncCall(ticket, callSpan);
363
- finishTicketDelivery(pi, ticket);
359
+ // A "cancelling" ticket that outlived its workers settles as
360
+ // "cancelled": the per-task results record what actually happened;
361
+ // the ticket state reports that the batch was aborted by the caller.
362
+ settleTicket(ticket, {
363
+ status:
364
+ ticket.status === "running"
365
+ ? resolveFinalTicketStatus(ticket)
366
+ : "cancelled",
367
+ });
368
+ finishLiveSettlement(ticket);
364
369
  })
365
370
  .catch((err) => {
366
371
  // Defense-in-depth — should not happen if individual tasks catch properly.
@@ -370,17 +375,11 @@ export function dispatchAsync(input: AsyncDispatchInput): DelegateToolResult {
370
375
  settleAsyncCall(ticket, callSpan);
371
376
  return;
372
377
  }
373
- if (ticket.status === "cancelling") {
374
- ticket.status = "cancelled";
375
- } else if (ticket.status === "running") {
376
- ticket.status = "failed";
377
- }
378
- ticket.error = err instanceof Error ? err.message : String(err);
379
- ticket.completedAt = Date.now();
380
- syncTicketBusyIndex(ticket);
381
- syncDelegateStatus();
382
- settleAsyncCall(ticket, callSpan);
383
- finishTicketDelivery(pi, ticket);
378
+ settleTicket(ticket, {
379
+ status: ticket.status === "cancelling" ? "cancelled" : "failed",
380
+ error: err instanceof Error ? err.message : String(err),
381
+ });
382
+ finishLiveSettlement(ticket);
384
383
  });
385
384
  ticket.completion = completion;
386
385
 
package/extension.ts CHANGED
@@ -18,10 +18,8 @@ import {
18
18
  } from "./dispatch.ts";
19
19
  import { renderDelegateCall, renderDelegateResult } from "./render-result.ts";
20
20
  import { hostCompatError } from "./host-compat.ts";
21
- import {
22
- invalidateHostDepsCache,
23
- registerProviderExtensionNotifier,
24
- } from "./host.ts";
21
+ import { invalidateHostDepsCache } from "./host.ts";
22
+ import { registerProviderExtensionNotifier } from "./provider-extensions.ts";
25
23
  import { recordTreeNavigation, resetLeafTracking } from "./leaf.ts";
26
24
  import { closeAllPooledAgents } from "./pool.ts";
27
25
  import {
package/format.ts CHANGED
@@ -442,7 +442,7 @@ export function formatCompletedTask(
442
442
 
443
443
  // ── Shared live-progress row helpers ───────────────────────────────────────
444
444
  // These dedupe the per-task computations the LLM-facing poll view
445
- // (tickets.handlePoll) and the TUI branches (render-branches) both need. Each is
445
+ // (ticket-format.ts) and the TUI branches (render-branches) both need. Each is
446
446
  // pure over TaskProgress/TaskResult, so it tests without a renderer.
447
447
 
448
448
  /** The in-flight tool activity (no result yet), or null — the "current thing
@@ -499,7 +499,7 @@ export function waitingLabel(runningCount: number, cap: number): string {
499
499
 
500
500
  /** Touched-files summary relative to cwd ("src/a.ts, src/b.ts"), or null when
501
501
  * none resolve under cwd. Was byte-for-byte duplicated in formatCompletedTask
502
- * and tickets.handlePoll. */
502
+ * and ticket-format.ts. */
503
503
  export function relativeTouchedSummary(
504
504
  files: string[],
505
505
  cwd: string,
package/host-cache.ts ADDED
@@ -0,0 +1,70 @@
1
+ /**
2
+ * A generation-guarded async memo.
3
+ *
4
+ * Host deps are expensive (`ResourceLoader.reload()` is ~1.2s cold) and are
5
+ * shared across the parallel tasks of a single delegate dispatch, then thrown
6
+ * away so the next dispatch observes edits to auth, models, settings, and
7
+ * context files. That gives three requirements the plain
8
+ * `Map<string, Promise<T>>` pattern does not meet:
9
+ *
10
+ * - **in-flight dedup** — concurrent tasks with the same key must await one
11
+ * build, not start N reloads;
12
+ * - **generation guard** — an invalidation during a build must not let that
13
+ * older build install its now-stale value, nor let it delete the in-flight
14
+ * marker a newer build has since installed for the same key;
15
+ * - **one invalidation path** — every reset goes through `invalidate()`.
16
+ * Before this was factored out, two test-only helpers cleared the maps
17
+ * directly without bumping the generation, leaving exactly the stale-write
18
+ * window the guard exists to close.
19
+ */
20
+ export class GenerationCache<T> {
21
+ private entries = new Map<string, T>();
22
+ private inflight = new Map<string, Promise<T>>();
23
+ private generation = 0;
24
+
25
+ /**
26
+ * Return the cached value for `key`, joining an in-flight build or starting
27
+ * one. When `cacheable` is false the value is built and returned without ever
28
+ * being stored or shared.
29
+ */
30
+ async resolve(
31
+ key: string,
32
+ cacheable: boolean,
33
+ build: () => Promise<T>,
34
+ ): Promise<T> {
35
+ if (!cacheable) return build();
36
+
37
+ const cached = this.entries.get(key);
38
+ if (cached !== undefined) return cached;
39
+ const pending = this.inflight.get(key);
40
+ if (pending) return pending;
41
+
42
+ const generation = this.generation;
43
+ const promise = build().then((value) => {
44
+ // A build that outlived its generation is stale by definition; return it
45
+ // to its own caller but never publish it.
46
+ if (this.generation === generation) this.entries.set(key, value);
47
+ return value;
48
+ });
49
+ this.inflight.set(key, promise);
50
+ try {
51
+ return await promise;
52
+ } finally {
53
+ // An invalidation can let a newer generation install its own in-flight
54
+ // build for this key. Never let the older promise delete that marker.
55
+ if (this.inflight.get(key) === promise) this.inflight.delete(key);
56
+ }
57
+ }
58
+
59
+ /** Drop every cached and in-flight value, invalidating builds already running. */
60
+ invalidate(): void {
61
+ this.generation++;
62
+ this.entries.clear();
63
+ this.inflight.clear();
64
+ }
65
+
66
+ /** Cached values only — in-flight builds are not observable here. */
67
+ values(): Iterable<T> {
68
+ return this.entries.values();
69
+ }
70
+ }