@cad0p/pi-subagents-tintinweb 0.14.2 → 0.22.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.
@@ -13,9 +13,10 @@
13
13
 
14
14
  import { Editor, isKeyRelease, Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
15
15
  import type { AgentManager } from "../agent-manager.js";
16
+ import { toSingleLine } from "../text-safety.js";
16
17
  import type { AgentRecord } from "../types.js";
17
- import { getLifetimeTotal } from "../usage.js";
18
- import { type AgentActivity, getDisplayName, type Theme } from "./agent-widget.js";
18
+ import { getLifetimeCost, getLifetimeTotal } from "../usage.js";
19
+ import { type AgentActivity, formatCost, getDisplayName, type Theme } from "./agent-widget.js";
19
20
  import { ConversationViewer, VIEWPORT_HEIGHT_PCT } from "./conversation-viewer.js";
20
21
 
21
22
  /** Widget key for the below-editor fleet list. */
@@ -93,6 +94,12 @@ export class FleetList {
93
94
  constructor(
94
95
  private manager: AgentManager,
95
96
  private agentActivity: Map<string, AgentActivity>,
97
+ /**
98
+ * Read live at render time. Whether each row shows an estimated cost after
99
+ * its token count. Defaults to off — the extension supplies the user's
100
+ * `showCost` setting.
101
+ */
102
+ private showCost: () => boolean = () => false,
96
103
  ) {}
97
104
 
98
105
  // ---- Lifecycle ----
@@ -188,7 +195,7 @@ export class FleetList {
188
195
  private agentRecords(): AgentRecord[] {
189
196
  const now = Date.now();
190
197
  return this.manager.listAgents()
191
- .filter(a => a.session && (
198
+ .filter(a => !a.parentAgentId && a.session && (
192
199
  a.status === "running" || a.status === "queued"
193
200
  || a.id === this.viewingAgentId
194
201
  || (a.completedAt != null && now - a.completedAt < FINISHED_LINGER_MS)
@@ -288,7 +295,7 @@ export class FleetList {
288
295
  const record = entry.record;
289
296
  if (!this.ui) return;
290
297
  if (!record.session) {
291
- this.ui.notify(`Agent is ${record.status} — no session available.`, "info");
298
+ this.ui.notify(`Agent is ${toSingleLine(record.status)} — no session available.`, "info");
292
299
  return;
293
300
  }
294
301
  const session = record.session;
@@ -306,10 +313,11 @@ export class FleetList {
306
313
  theme,
307
314
  done,
308
315
  () => {
309
- if (this.manager.abort(record.id)) this.ui?.notify(`Stopped "${record.description}".`, "info");
316
+ if (this.manager.abort(record.id)) this.ui?.notify(`Stopped "${toSingleLine(record.description)}".`, "info");
310
317
  },
311
318
  keybindings,
312
319
  (message: string) => this.manager.steer(record.id, message),
320
+ this.showCost(),
313
321
  );
314
322
  },
315
323
  {
@@ -371,10 +379,13 @@ export class FleetList {
371
379
  }
372
380
 
373
381
  private renderAgentRow(rosterIndex: number, sel: number, record: AgentRecord, width: number, theme: Theme): string {
374
- const left = ` ${this.bullet(rosterIndex, sel, theme)} ${theme.fg("muted", getDisplayName(record.type))} ${record.description}`;
375
- const tokens = getLifetimeTotal(this.agentActivity.get(record.id)?.lifetimeUsage ?? record.lifetimeUsage);
382
+ const left = ` ${this.bullet(rosterIndex, sel, theme)} ${theme.fg("muted", toSingleLine(getDisplayName(record.type)))} ${toSingleLine(record.description)}`;
383
+ const usage = this.agentActivity.get(record.id)?.lifetimeUsage ?? record.lifetimeUsage;
384
+ const tokens = getLifetimeTotal(usage);
376
385
  const elapsedMs = (record.completedAt ?? Date.now()) - record.startedAt; // freezes once finished
377
- const right = theme.fg("dim", `${formatFleetElapsed(elapsedMs)} · ${formatFleetTokens(tokens)}`);
386
+ const cost = this.showCost() ? formatCost(getLifetimeCost(usage)) : "";
387
+ const stats = `${formatFleetElapsed(elapsedMs)} · ${formatFleetTokens(tokens)}${cost ? ` · ${cost}` : ""}`;
388
+ const right = theme.fg("dim", stats);
378
389
  return rightAlign(left, right, width);
379
390
  }
380
391
  }
@@ -10,6 +10,7 @@
10
10
 
11
11
  import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
12
12
  import type { SubagentScheduler } from "../schedule.js";
13
+ import { safeTruncate, stripControlChars, toSingleLine } from "../text-safety.js";
13
14
  import type { ScheduledSubagent } from "../types.js";
14
15
 
15
16
  /** Format an ISO timestamp as relative time ("in 4h", "2d ago", "—"). */
@@ -40,29 +41,44 @@ function statusIcon(j: ScheduledSubagent): string {
40
41
  /** Compact selectable row — name, schedule, agent type, next/last run, count. */
41
42
  function formatJob(j: ScheduledSubagent, scheduler: SubagentScheduler): string {
42
43
  const next = scheduler.getNextRun(j.id);
44
+ // runCount is typed number but comes from unvalidated JSON; coerce before the
45
+ // single-line sanitizer, which returns "" for non-string input.
46
+ const runCount = Number(j.runCount);
43
47
  return [
44
48
  statusIcon(j),
45
- j.name.padEnd(18).slice(0, 18),
46
- j.schedule.padEnd(14).slice(0, 14),
47
- `[${j.subagent_type}]`,
49
+ safeTruncate(toSingleLine(j.name), 18).padEnd(18),
50
+ safeTruncate(toSingleLine(j.schedule), 14).padEnd(14),
51
+ `[${toSingleLine(j.subagent_type)}]`,
48
52
  `next ${relTime(next)}`,
49
53
  `last ${relTime(j.lastRun)}`,
50
- `runs ${j.runCount}`,
54
+ `runs ${toSingleLine(String(Number.isFinite(runCount) ? runCount : 0))}`,
51
55
  ].join(" ");
52
56
  }
53
57
 
54
58
  /** Multi-line details block for the cancel confirm. */
55
59
  function formatDetails(j: ScheduledSubagent, scheduler: SubagentScheduler): string {
56
60
  const next = scheduler.getNextRun(j.id) ?? "—";
61
+ // The prompt is the only multi-line field and goes last, under its own
62
+ // label, so nothing it contains can forge a metadata line above it. The
63
+ // store is not validated per field, so a corrupted entry can hand it a
64
+ // non-string; coerce before the sanitizer touches it.
65
+ const rawPrompt = typeof j.prompt === "string" ? j.prompt : "";
66
+ const prompt = stripControlChars(rawPrompt);
67
+ const promptPreview = prompt.length > 200 ? `${safeTruncate(prompt, 200)}…` : prompt;
68
+ // runCount is typed number but comes from unvalidated JSON; coerce before the
69
+ // single-line sanitizer, which returns "" for non-string input.
70
+ const runCount = Number(j.runCount);
57
71
  return [
58
- `name: ${j.name}`,
59
- `schedule: ${j.schedule} (${j.scheduleType})`,
60
- `agent: ${j.subagent_type}`,
61
- `prompt: ${j.prompt.slice(0, 200)}${j.prompt.length > 200 ? "…" : ""}`,
62
- `created: ${j.createdAt}`,
63
- `last run: ${j.lastRun ?? "—"} (${j.lastStatus ?? "—"})`,
64
- `next run: ${next}`,
65
- `runs: ${j.runCount}`,
72
+ `name: ${toSingleLine(j.name)}`,
73
+ `schedule: ${toSingleLine(j.schedule)} (${toSingleLine(j.scheduleType)})`,
74
+ `agent: ${toSingleLine(j.subagent_type)}`,
75
+ `created: ${toSingleLine(j.createdAt)}`,
76
+ `last run: ${toSingleLine(j.lastRun ?? "—")} (${toSingleLine(j.lastStatus ?? "—")})`,
77
+ `next run: ${toSingleLine(next)}`,
78
+ `runs: ${toSingleLine(String(Number.isFinite(runCount) ? runCount : 0))}`,
79
+ "",
80
+ "prompt:",
81
+ promptPreview,
66
82
  ].join("\n");
67
83
  }
68
84
 
@@ -85,20 +101,48 @@ export async function showSchedulesMenu(
85
101
  return;
86
102
  }
87
103
 
88
- const labels = jobs.map(j => formatJob(j, scheduler));
104
+ // Labels are the user-facing rows, so two jobs whose names collapse to the
105
+ // same text would otherwise be indistinguishable. Make each generated label
106
+ // unique and resolve the selected label back to its job, so confirm/cancel
107
+ // act on the row the user picked.
108
+ const jobByLabel = new Map<string, ScheduledSubagent>();
109
+ const nextSuffix = new Map<string, number>();
110
+ const labels = jobs.map(j => {
111
+ const base = formatJob(j, scheduler);
112
+ let label = base;
113
+ if (jobByLabel.has(label)) {
114
+ // A repeated base (rows can truncate to the same text) is load-bearing:
115
+ // the suffix keeps the labels distinct. Resume from the last used suffix
116
+ // so a fully-conflicting set costs one step per row.
117
+ let n = nextSuffix.get(base) ?? 2;
118
+ // The rescan is unreachable today (every base ends with its `runs N`
119
+ // tail), but keep it: a duplicate label would let the user cancel the
120
+ // wrong job if the row shape changes.
121
+ while (jobByLabel.has(`${base} (${n})`)) n++;
122
+ label = `${base} (${n})`;
123
+ nextSuffix.set(base, n + 1);
124
+ }
125
+ jobByLabel.set(label, j);
126
+ return label;
127
+ });
89
128
  const choice = await ctx.ui.select(
90
129
  `Scheduled jobs (${jobs.length}) — select to cancel`,
91
130
  labels,
92
131
  );
93
132
  if (!choice) return;
94
133
 
95
- const idx = labels.indexOf(choice);
96
- if (idx < 0) return;
97
- const job = jobs[idx];
134
+ const job = jobByLabel.get(choice);
135
+ if (!job) return;
98
136
 
99
- const ok = await ctx.ui.confirm(`Cancel "${job.name}"?`, formatDetails(job, scheduler));
137
+ const ok = await ctx.ui.confirm(`Cancel "${toSingleLine(job.name)}"?`, formatDetails(job, scheduler));
100
138
  if (!ok) return;
101
139
 
102
- scheduler.removeJob(job.id);
103
- ctx.ui.notify(`Cancelled "${job.name}".`, "info");
140
+ const removed = scheduler.removeJob(job.id);
141
+ if (removed) {
142
+ ctx.ui.notify(`Cancelled "${toSingleLine(job.name)}".`, "info");
143
+ } else {
144
+ // The record vanished between the list read and the removal — do not
145
+ // claim a cancellation that did not happen.
146
+ ctx.ui.notify(`Could not cancel "${toSingleLine(job.name)}" — it is no longer a scheduled job.`, "warning");
147
+ }
104
148
  }
package/src/usage.ts CHANGED
@@ -1,30 +1,123 @@
1
1
  /** usage.ts — Token usage: shapes, accumulator operators, session-stats readers. */
2
2
 
3
+ import type { Usage } from "@earendil-works/pi-ai";
4
+
3
5
  /**
4
6
  * Lifetime usage components, accumulated via `message_end` events. Survives
5
7
  * compaction (which replaces session.state.messages and would reset any
6
- * stats-derived sum). cacheRead is excluded because each turn's cacheRead is
7
- * the cumulative cached prefix re-read on that one call — summing across
8
- * turns counts the prefix N times. See issue #38.
8
+ * stats-derived sum). cacheRead is excluded from `getLifetimeTotal` because
9
+ * each turn's cacheRead is the cumulative cached prefix re-read on that one
10
+ * call — summing across turns counts the prefix N times. See issue #38.
11
+ *
12
+ * That exclusion is about this *display* total, not about what was billed: the
13
+ * prefix really is re-read and re-charged on every call. So `cacheRead` is
14
+ * accumulated here anyway, kept out of `getLifetimeTotal` and used only where
15
+ * billing is the question — reporting to the parent session, whose own messages
16
+ * pi counts the same way (`addUsageToTotals`). Reporting 0 there would make a
17
+ * subagent's rows count differently from every other row in one total.
18
+ *
19
+ * `cost` is a plain sum for the same reason: it is what pi charged for that one
20
+ * message (`usage.cost.total`, priced from the model's rates), not a cumulative
21
+ * figure. Both are optional because a model with no pricing data reports no
22
+ * cost, and because every accumulator predates them; absent reads as 0.
9
23
  */
10
- export type LifetimeUsage = { input: number; output: number; cacheWrite: number };
24
+ export type LifetimeUsage = { input: number; output: number; cacheWrite: number; cacheRead?: number; cost?: number };
11
25
 
12
- /** Sum of lifetime usage components, or 0 if undefined. */
26
+ /**
27
+ * Sum of lifetime *token* components for DISPLAY, or 0 if undefined.
28
+ * Deliberately excludes `cacheRead` (see above) and `cost` — that is money, not
29
+ * tokens, and lives on the same object only because it accumulates on the same
30
+ * events.
31
+ */
13
32
  export function getLifetimeTotal(u?: LifetimeUsage): number {
14
33
  return u ? u.input + u.output + u.cacheWrite : 0;
15
34
  }
16
35
 
36
+ /** Accumulated cost in USD, or 0 when unpriced/undefined. */
37
+ export function getLifetimeCost(u?: LifetimeUsage): number {
38
+ return u?.cost ?? 0;
39
+ }
40
+
17
41
  /** Add a usage delta into a target accumulator (mutates target). */
18
42
  export function addUsage(into: LifetimeUsage, delta: LifetimeUsage): void {
19
43
  into.input += delta.input;
20
44
  into.output += delta.output;
21
45
  into.cacheWrite += delta.cacheWrite;
46
+ if (delta.cacheRead) into.cacheRead = (into.cacheRead ?? 0) + delta.cacheRead;
47
+ if (delta.cost) into.cost = (into.cost ?? 0) + delta.cost;
48
+ }
49
+
50
+ /**
51
+ * Render an accumulator as a pi `Usage`, or undefined when nothing was spent —
52
+ * callers attach nothing rather than a zero, so a consumer can tell "spent
53
+ * nothing" from "never ran".
54
+ *
55
+ * `cacheRead` IS included, unlike in `getLifetimeTotal`: pi sums it across a
56
+ * session's own assistant messages, and the prefix genuinely is re-read and
57
+ * re-billed on every call. Only `total` is populated on the cost breakdown; pi
58
+ * reads nothing else from it, and the per-kind split is not tracked.
59
+ *
60
+ * This is pi's own shape on purpose: every extension-facing payload that carries
61
+ * spend takes a whole `Usage` (`AgentToolResult.usage`, `ToolResultEventResult`,
62
+ * `AssistantMessage`, …), never a flattened cost — pi flattens only in computed
63
+ * read APIs it expects you to render, like `SessionStats`. Handing over pi's own
64
+ * type means `usage.cost.total` is where a listener already looks, and whatever
65
+ * pi adds to `Usage` later arrives without a change here.
66
+ */
67
+ export function toReportedUsage(u: LifetimeUsage): Usage | undefined {
68
+ const { input, output, cacheWrite, cacheRead = 0, cost = 0 } = u;
69
+ if (input === 0 && output === 0 && cacheWrite === 0 && cacheRead === 0 && cost === 0) return undefined;
70
+ return {
71
+ input,
72
+ output,
73
+ cacheRead,
74
+ cacheWrite,
75
+ totalTokens: input + output + cacheRead + cacheWrite,
76
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: cost },
77
+ };
78
+ }
79
+
80
+ /**
81
+ * Subagent spend that the parent session has not been told about yet.
82
+ *
83
+ * Subagents run in their own pi sessions, so none of what they spend appears in
84
+ * the parent's `getSessionStats()`. Pi does aggregate `toolResult.usage` into
85
+ * those stats, though — so the way back into the parent's footer and `/cost` is
86
+ * to hang the spend on a tool result. Background agents finish between tool
87
+ * calls with nothing to hang it on, hence a pool: every assistant message lands
88
+ * here as it happens, and the next tool result this extension returns carries
89
+ * whatever has accumulated.
90
+ *
91
+ * Drain empties it, so each message is reported exactly once no matter how many
92
+ * results are returned or how many agents were running.
93
+ */
94
+ export class PendingUsagePool {
95
+ private pending: LifetimeUsage = { input: 0, output: 0, cacheWrite: 0, cacheRead: 0, cost: 0 };
96
+ private dirty = false;
97
+
98
+ add(delta: LifetimeUsage): void {
99
+ addUsage(this.pending, delta);
100
+ this.dirty = true;
101
+ }
102
+
103
+ /**
104
+ * Take everything accumulated so far as a pi `Usage`, resetting the pool.
105
+ * Returns undefined when nothing is pending, so callers can leave the tool
106
+ * result untouched rather than attaching a zero.
107
+ */
108
+ drain(): Usage | undefined {
109
+ if (!this.dirty) return undefined;
110
+ const drained = toReportedUsage(this.pending);
111
+ this.pending = { input: 0, output: 0, cacheWrite: 0, cacheRead: 0, cost: 0 };
112
+ this.dirty = false;
113
+ return drained;
114
+ }
22
115
  }
23
116
 
24
117
  /** Minimal shape we read from upstream `getSessionStats()`. */
25
118
  export type SessionStatsLike = {
26
119
  tokens: { input: number; output: number; cacheWrite: number };
27
- contextUsage?: { percent: number | null };
120
+ contextUsage?: { percent: number | null; contextWindow?: number | null };
28
121
  };
29
122
  export type SessionLike = { getSessionStats(): SessionStatsLike };
30
123
 
@@ -55,6 +148,33 @@ export function getSessionTokens(session: SessionLike | undefined): number {
55
148
  */
56
149
  export function getSessionContextPercent(session: SessionLike | undefined): number | null {
57
150
  if (!session) return null;
58
- try { return session.getSessionStats().contextUsage?.percent ?? null; }
59
- catch { return null; }
151
+ try {
152
+ const percent = session.getSessionStats().contextUsage?.percent;
153
+ // A non-finite/negative percent would render `Context: NaN%` (or a nonsense
154
+ // negative) in the tool text and skew the widget/viewer thresholds.
155
+ return typeof percent === "number" && Number.isFinite(percent) && percent >= 0 ? percent : null;
156
+ } catch { return null; }
157
+ }
158
+
159
+ /** Format a context-window size for the report: "1.0M" at ≥1M, else "NNNk". */
160
+ function formatContextWindow(contextWindow: number): string {
161
+ return contextWindow >= 1_000_000 ? "1.0M" : `${(contextWindow / 1000).toFixed(0)}k`;
162
+ }
163
+
164
+ /**
165
+ * Context-window utilization as `<percent>% of <window>` (e.g. "61.0% of 200k"),
166
+ * or null when unavailable (no model contextWindow, post-compaction, a
167
+ * disposed/throwing session, a non-finite/negative percent, or a non-finite
168
+ * window ≤ 0). A real 0.0% renders — the percent gate is not 0.
169
+ */
170
+ export function formatSessionContext(session: SessionLike | undefined): string | null {
171
+ if (!session) return null;
172
+ try {
173
+ const usage = session.getSessionStats().contextUsage;
174
+ const percent = usage?.percent;
175
+ const contextWindow = usage?.contextWindow;
176
+ if (percent == null || !Number.isFinite(percent) || percent < 0) return null;
177
+ if (contextWindow == null || !Number.isFinite(contextWindow) || contextWindow <= 0) return null;
178
+ return `${percent.toFixed(1)}% of ${formatContextWindow(contextWindow)}`;
179
+ } catch { return null; }
60
180
  }
package/src/worktree.ts CHANGED
@@ -28,6 +28,25 @@ export interface WorktreeInfo {
28
28
  workPath: string;
29
29
  }
30
30
 
31
+ /**
32
+ * Project-wide switch for worktree isolation (`worktreeIsolation` in
33
+ * subagents.json). Default `true` — unchanged behaviour.
34
+ *
35
+ * The `"off"` isolation value gives a model a legal way to decline a worktree,
36
+ * but it still depends on the model choosing it. This is the deterministic half
37
+ * of the same fix: on a large repo where every worktree costs real time and
38
+ * disk, turning it off means no caller can create one, whatever it passes.
39
+ */
40
+ let worktreeIsolationEnabled = true;
41
+
42
+ export function setWorktreeIsolationEnabled(enabled: boolean): void {
43
+ worktreeIsolationEnabled = enabled;
44
+ }
45
+
46
+ export function isWorktreeIsolationEnabled(): boolean {
47
+ return worktreeIsolationEnabled;
48
+ }
49
+
31
50
  export interface WorktreeCleanupResult {
32
51
  /** Whether changes were found in the worktree. */
33
52
  hasChanges: boolean;
package/src/group-join.ts DELETED
@@ -1,141 +0,0 @@
1
- /**
2
- * group-join.ts — Manages grouped background agent completion notifications.
3
- *
4
- * Instead of each agent individually nudging the main agent on completion,
5
- * agents in a group are held until all complete (or a timeout fires),
6
- * then a single consolidated notification is sent.
7
- */
8
-
9
- import type { AgentRecord } from "./types.js";
10
-
11
- export type DeliveryCallback = (records: AgentRecord[], partial: boolean) => void;
12
-
13
- interface AgentGroup {
14
- groupId: string;
15
- agentIds: Set<string>;
16
- completedRecords: Map<string, AgentRecord>;
17
- timeoutHandle?: ReturnType<typeof setTimeout>;
18
- delivered: boolean;
19
- /** Shorter timeout for stragglers after a partial delivery. */
20
- isStraggler: boolean;
21
- }
22
-
23
- /** Default timeout: 30s after first completion in a group. */
24
- const DEFAULT_TIMEOUT = 30_000;
25
- /** Straggler re-batch timeout: 15s. */
26
- const STRAGGLER_TIMEOUT = 15_000;
27
-
28
- export class GroupJoinManager {
29
- private groups = new Map<string, AgentGroup>();
30
- private agentToGroup = new Map<string, string>();
31
-
32
- constructor(
33
- private deliverCb: DeliveryCallback,
34
- private groupTimeout = DEFAULT_TIMEOUT,
35
- ) {}
36
-
37
- /** Register a group of agent IDs that should be joined. */
38
- registerGroup(groupId: string, agentIds: string[]): void {
39
- const group: AgentGroup = {
40
- groupId,
41
- agentIds: new Set(agentIds),
42
- completedRecords: new Map(),
43
- delivered: false,
44
- isStraggler: false,
45
- };
46
- this.groups.set(groupId, group);
47
- for (const id of agentIds) {
48
- this.agentToGroup.set(id, groupId);
49
- }
50
- }
51
-
52
- /**
53
- * Called when an agent completes.
54
- * Returns:
55
- * - 'pass' — agent is not grouped, caller should send individual nudge
56
- * - 'held' — result held, waiting for group completion
57
- * - 'delivered' — this completion triggered the group notification
58
- */
59
- onAgentComplete(record: AgentRecord): 'delivered' | 'held' | 'pass' {
60
- const groupId = this.agentToGroup.get(record.id);
61
- if (!groupId) return 'pass';
62
-
63
- const group = this.groups.get(groupId);
64
- if (!group || group.delivered) return 'pass';
65
-
66
- group.completedRecords.set(record.id, record);
67
-
68
- // All done — deliver immediately
69
- if (group.completedRecords.size >= group.agentIds.size) {
70
- this.deliver(group, false);
71
- return 'delivered';
72
- }
73
-
74
- // First completion in this batch — start timeout
75
- if (!group.timeoutHandle) {
76
- const timeout = group.isStraggler ? STRAGGLER_TIMEOUT : this.groupTimeout;
77
- group.timeoutHandle = setTimeout(() => {
78
- this.onTimeout(group);
79
- }, timeout);
80
- }
81
-
82
- return 'held';
83
- }
84
-
85
- private onTimeout(group: AgentGroup): void {
86
- if (group.delivered) return;
87
- group.timeoutHandle = undefined;
88
-
89
- // Partial delivery — some agents still running
90
- const remaining = new Set<string>();
91
- for (const id of group.agentIds) {
92
- if (!group.completedRecords.has(id)) remaining.add(id);
93
- }
94
-
95
- // Clean up agentToGroup for delivered agents (they won't complete again)
96
- for (const id of group.completedRecords.keys()) {
97
- this.agentToGroup.delete(id);
98
- }
99
-
100
- // Deliver what we have
101
- this.deliverCb([...group.completedRecords.values()], true);
102
-
103
- // Set up straggler group for remaining agents
104
- group.completedRecords.clear();
105
- group.agentIds = remaining;
106
- group.isStraggler = true;
107
- // Timeout will be started when the next straggler completes
108
- }
109
-
110
- private deliver(group: AgentGroup, partial: boolean): void {
111
- if (group.timeoutHandle) {
112
- clearTimeout(group.timeoutHandle);
113
- group.timeoutHandle = undefined;
114
- }
115
- group.delivered = true;
116
- this.deliverCb([...group.completedRecords.values()], partial);
117
- this.cleanupGroup(group.groupId);
118
- }
119
-
120
- private cleanupGroup(groupId: string): void {
121
- const group = this.groups.get(groupId);
122
- if (!group) return;
123
- for (const id of group.agentIds) {
124
- this.agentToGroup.delete(id);
125
- }
126
- this.groups.delete(groupId);
127
- }
128
-
129
- /** Check if an agent is in a group. */
130
- isGrouped(agentId: string): boolean {
131
- return this.agentToGroup.has(agentId);
132
- }
133
-
134
- dispose(): void {
135
- for (const group of this.groups.values()) {
136
- if (group.timeoutHandle) clearTimeout(group.timeoutHandle);
137
- }
138
- this.groups.clear();
139
- this.agentToGroup.clear();
140
- }
141
- }