@bermudi/pi-delegate 0.1.18 → 0.1.20

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/lifecycle.ts CHANGED
@@ -14,8 +14,7 @@ import type {
14
14
  TaskRunEnv,
15
15
  ToolActivity,
16
16
  } from "./types.ts";
17
- import * as pool from "./pool.ts";
18
- import { isSessionBusy } from "./tickets.ts";
17
+ import { getDefaultDelegateRuntime, type DelegateRuntime } from "./runtime.ts";
19
18
  import {
20
19
  createSubagentSessionManager,
21
20
  persistSessionHeader,
@@ -50,10 +49,6 @@ let runAgentSessionForTesting: RunAgentSession = runAgentSession;
50
49
  type CreateScratchWorkspace = typeof createScratchWorkspace;
51
50
  let createScratchWorkspaceForTesting: CreateScratchWorkspace =
52
51
  createScratchWorkspace;
53
- type DetachQuarantinedPooledSession =
54
- typeof pool._quarantinePooledAgentWithoutDisposal;
55
- let detachQuarantinedPooledSessionForTesting: DetachQuarantinedPooledSession =
56
- pool._quarantinePooledAgentWithoutDisposal;
57
52
 
58
53
  export function _setRunAgentSessionForTesting(
59
54
  override: RunAgentSession | undefined,
@@ -70,10 +65,11 @@ export function _setCreateScratchWorkspaceForTesting(
70
65
 
71
66
  /** @internal Simulate a pooled-detachment invariant failure in lifecycle tests. */
72
67
  export function _setQuarantinePooledSessionDetachForTesting(
73
- override: DetachQuarantinedPooledSession | undefined,
68
+ override:
69
+ ((sessionId: string, expectedSession: AgentSession) => boolean) | undefined,
70
+ runtime: DelegateRuntime = getDefaultDelegateRuntime(),
74
71
  ): void {
75
- detachQuarantinedPooledSessionForTesting =
76
- override ?? pool._quarantinePooledAgentWithoutDisposal;
72
+ runtime.pool._setQuarantinePooledAgentWithoutDisposalForTesting(override);
77
73
  }
78
74
 
79
75
  /**
@@ -242,14 +238,16 @@ function disposeSession(session: AgentSession, description: string): void {
242
238
  /** Detach an abandoned session from every owner immediately, then dispose it
243
239
  * only after runner's background termination monitor proves quiescence. */
244
240
  function quarantineAcquiredSession(
241
+ env: TaskRunEnv,
245
242
  task: ResolvedTask,
246
243
  acquired: AcquiredSession,
247
244
  quarantine: SessionQuarantine,
248
245
  ): void {
249
246
  let mayDisposeAfterSafety = acquired.lifecycleOwnsSession;
250
247
  if (!acquired.lifecycleOwnsSession) {
248
+ const runtime = env.runtime!;
251
249
  const detached = task.sessionId
252
- ? detachQuarantinedPooledSessionForTesting(
250
+ ? runtime.pool.quarantinePooledAgentWithoutDisposal(
253
251
  task.sessionId,
254
252
  acquired.session,
255
253
  )
@@ -363,6 +361,8 @@ export function updateProgressFromRun(
363
361
  u.durationMs,
364
362
  );
365
363
  p.lastActivityAt = u.lastActivityAt;
364
+ p.assistantPreview = u.assistantPreview;
365
+ p.activity = u.activity;
366
366
  p.activities = mergeToolActivities(p.activities, u.activities);
367
367
  p.failureKind = u.failureKind;
368
368
  }
@@ -617,11 +617,12 @@ type AcquireResult = AcquiredSession | { error: TaskResult };
617
617
  * checkout is pure (no lastUsed bump); lastUsed is bumped by commit().
618
618
  */
619
619
  function checkoutPooledSession(
620
+ env: TaskRunEnv,
620
621
  task: ResolvedTask,
621
622
  p: TaskProgress,
622
623
  ): AcquireResult | undefined {
623
624
  if (!task.sessionId) return undefined;
624
- const co = pool.checkout(task.sessionId, {
625
+ const co = env.runtime!.pool.checkout(task.sessionId, {
625
626
  cwd: task.cwd,
626
627
  thinking: task.thinking,
627
628
  tools: task.tools,
@@ -762,7 +763,7 @@ async function acquireAgentSession(
762
763
  p: TaskProgress,
763
764
  ): Promise<AcquireResult> {
764
765
  if (task.sessionId) {
765
- const pooled = checkoutPooledSession(task, p);
766
+ const pooled = checkoutPooledSession(env, task, p);
766
767
  if (pooled) return pooled;
767
768
  }
768
769
  if (task.resumeFrom) return resumeFromSessionFile(env, task, task.resumeFrom);
@@ -813,6 +814,10 @@ export async function runResolvedTask(
813
814
  p: TaskProgress,
814
815
  taskIndex: number,
815
816
  ): Promise<TaskResult> {
817
+ // Ensure every lifecycle call operates on an explicit runtime. Older callers
818
+ // (and some test fixtures) do not inject one, so the default runtime is the
819
+ // backward-compatible fallback.
820
+ env.runtime ??= getDefaultDelegateRuntime();
816
821
  return withResumeTranscriptLock(task.resumeFrom, async (transcript) => {
817
822
  let executionTask = task;
818
823
  if (task.resumeFrom) {
@@ -879,8 +884,9 @@ export async function runResolvedTask(
879
884
  return runResolvedTaskUnlocked(env, executionTask, p, taskIndex);
880
885
  };
881
886
 
887
+ const runtime = env.runtime!;
882
888
  return executionTask.sessionId
883
- ? pool.withSessionLock(executionTask.sessionId, runLocked)
889
+ ? runtime.pool.withSessionLock(executionTask.sessionId, runLocked)
884
890
  : runLocked();
885
891
  });
886
892
  }
@@ -1198,7 +1204,10 @@ async function applySessionAction(
1198
1204
  // The per-session lock for action-based operations is already held by the
1199
1205
  // outer runResolvedTask() wrapper. Use the internal close helper to avoid a
1200
1206
  // reentrant deadlock on the same key.
1201
- const closed = await pool._closePooledAgentWithoutLock(task.sessionId);
1207
+ const runtime = env.runtime!;
1208
+ const closed = await runtime.pool.closePooledAgentWithoutLock(
1209
+ task.sessionId,
1210
+ );
1202
1211
  return finishTask(
1203
1212
  env,
1204
1213
  p,
@@ -1213,12 +1222,13 @@ async function applySessionAction(
1213
1222
  }
1214
1223
 
1215
1224
  if (task.sessionAction === "list") {
1225
+ const runtime = env.runtime!;
1216
1226
  return finishTask(
1217
1227
  env,
1218
1228
  p,
1219
1229
  completeSessionAction(
1220
1230
  task,
1221
- `Active sessions:\n${pool.listPooledAgents().join("\n")}`,
1231
+ `Active sessions:\n${runtime.pool.listPooledAgents().join("\n")}`,
1222
1232
  Date.now() - env.delegateStartedAt,
1223
1233
  ),
1224
1234
  );
@@ -1231,7 +1241,7 @@ function busySessionConflict(
1231
1241
  task: ResolvedTask,
1232
1242
  ): TaskResult | undefined {
1233
1243
  if (!task.sessionId) return undefined;
1234
- const busyTicketId = isSessionBusy(task.sessionId);
1244
+ const busyTicketId = env.runtime!.tickets.isSessionBusy(task.sessionId);
1235
1245
  if (busyTicketId && busyTicketId !== env.ticketId) {
1236
1246
  return failTask(
1237
1247
  task,
@@ -1303,6 +1313,7 @@ function noteAttemptProgress(
1303
1313
 
1304
1314
  /** Commit, record, or evict a pooled session after one prompt attempt. */
1305
1315
  async function settlePooledAttempt(
1316
+ env: TaskRunEnv,
1306
1317
  task: ResolvedTask,
1307
1318
  acquired: AcquiredSession,
1308
1319
  r: {
@@ -1313,6 +1324,7 @@ async function settlePooledAttempt(
1313
1324
  },
1314
1325
  sessionReleased: boolean,
1315
1326
  ): Promise<boolean> {
1327
+ const runtime = env.runtime!;
1316
1328
  if (!task.sessionId) return sessionReleased;
1317
1329
  if (acquired.lifecycleOwnsSession) {
1318
1330
  // Pool misses (including resumeFrom) transfer ownership only on
@@ -1323,7 +1335,7 @@ async function settlePooledAttempt(
1323
1335
  r.failureKind !== "stalled" &&
1324
1336
  r.failureKind !== "deadline_exceeded"
1325
1337
  ) {
1326
- const committed = pool.commit(task.sessionId, {
1338
+ const committed = runtime.pool.commit(task.sessionId, {
1327
1339
  session: acquired.session,
1328
1340
  sessionManager: acquired.sessionManager,
1329
1341
  sessionFile: acquired.sessionFile,
@@ -1353,7 +1365,7 @@ async function settlePooledAttempt(
1353
1365
  ) {
1354
1366
  try {
1355
1367
  return (
1356
- (await pool._closePooledAgentWithoutLock(task.sessionId)) ||
1368
+ (await runtime.pool.closePooledAgentWithoutLock(task.sessionId)) ||
1357
1369
  sessionReleased
1358
1370
  );
1359
1371
  } catch (error) {
@@ -1371,7 +1383,7 @@ async function settlePooledAttempt(
1371
1383
  if (r.failureKind !== "deadline_exceeded") {
1372
1384
  // Pool hits stay owned by the pool, and non-stalled, non-aborted
1373
1385
  // completions (including failed attempts) must still count usage.
1374
- pool.recordUse(task.sessionId, r.tokens);
1386
+ runtime.pool.recordUse(task.sessionId, r.tokens);
1375
1387
  }
1376
1388
  // Pre-prompt deadline (prompted === false): the pooled session was
1377
1389
  // checked out but never used. Leave it in the pool with no usage
@@ -1536,6 +1548,7 @@ async function runTaskAttempt(
1536
1548
  timing.taskStartedAt,
1537
1549
  timing.deadlineAt,
1538
1550
  env.config,
1551
+ env.pause ? { controller: env.pause, index: p.index } : undefined,
1539
1552
  );
1540
1553
 
1541
1554
  accounting.cumulativeTokens += r.tokens;
@@ -1558,12 +1571,13 @@ async function runTaskAttempt(
1558
1571
  );
1559
1572
 
1560
1573
  if (quarantine) {
1561
- quarantineAcquiredSession(task, acquired, quarantine);
1574
+ quarantineAcquiredSession(env, task, acquired, quarantine);
1562
1575
  // Neither lifecycle nor pool owns it now. The deferred safety callback is
1563
1576
  // the sole owner and finally below must not dispose it early.
1564
1577
  sessionReleased = true;
1565
1578
  } else {
1566
1579
  sessionReleased = await settlePooledAttempt(
1580
+ env,
1567
1581
  task,
1568
1582
  acquired,
1569
1583
  r,
package/manual.ts CHANGED
@@ -110,11 +110,11 @@ export function getSubagentManualMarkdown(
110
110
  'delegate({ tasks: [{ agent: "default", prompt: "Investigate the auth module" }] })',
111
111
  "```",
112
112
  "",
113
- 'Delegate subagents to execute tasks in parallel. Each subagent gets an independent conversation. A batch with multiple write-capable tasks in one shared tree is rejected before dispatch; run them sequentially, use synchronous one-shot `workspace: "isolated"` for Git-backed ordered reconciliation, or use disposable scratch workspaces.',
113
+ "Delegate subagents to execute tasks in parallel. Each subagent gets an independent conversation. Start with a built-in agent and its defaults: set only `agent` and `prompt`. Overlapping shared writers in one call run in task order.",
114
114
  "",
115
115
  "The three handles have different lifetimes:",
116
116
  "",
117
- "- **ticket** — controls one async batch with `poll`, `wait`, or `cancel`.",
117
+ "- **ticket** — controls one async batch with `poll`, `wait`, `pause`, `resume`, or `cancel`.",
118
118
  "- **sessionId** — a caller-chosen key for a live multi-turn worker, retained until close or parent shutdown.",
119
119
  "- **resumeFrom** — an absolute `.jsonl` transcript path used to recover an interrupted worker.",
120
120
  "",
@@ -138,9 +138,12 @@ export function getSubagentManualMarkdown(
138
138
  "",
139
139
  ...builtinLines,
140
140
  "",
141
- "Prefer `default` for general work: it is the only built-in guaranteed to run the parent's exact model and thinking. `scout`/`coder`/`reviewer` apply any configured delegate.json tiers (`agentOverrides`, `agentOverridesByParentModel`) and may run a different model or thinking level than the parent. `scout` is read-only, and `reviewer` runs in a disposable scratch workspace its writes are discarded by design. Choose a specialist for its role, not its name.",
141
+ "Start with a built-in and its configured defaults. Set only `agent` and `prompt`; omit `model`, `thinking`, `tools`, and `workspace` unless the user requests an override or a concrete task requirement makes the built-in default unsuitable.",
142
+ "A scout needs no scratch copy with its read-only defaults. Adding `bash` makes it write-capable regardless of its name; an explicit scratch copy can then be appropriate for disposable shell-based investigation.",
142
143
  "",
143
- "Fresh built-ins inherit the parent's exact model object and thinking level. A same-named Markdown file can override any built-in (first definition wins); an explicit `model` or `thinking` in that file replaces parent inheritance. Task-level `model`/`thinking`/`tools` always win. For `scout`/`coder`/`reviewer`, delegate.json overrides (`agentOverrides`, `agentOverridesByParentModel`) win over the Markdown file; `default` ignores overrides and uses only an explicit Markdown `model`/`thinking` when present. A prompt-only Markdown override keeps the built-in's tools and workspace, so `scout` stays read-only and `reviewer` stays scratch unless the file explicitly changes them. Parent extension/MCP tools are not copied. Parent-global `AGENTS.md` instructions are also excluded. Project-local context and skills are rebuilt for the task's `cwd`; per-task fields remain explicit overrides.",
144
+ "Prefer `default` for general work: it is the only built-in guaranteed to run the parent's exact model and thinking. `scout`/`coder`/`reviewer` apply any configured delegate.json tiers (`agentOverrides`, `agentOverridesByParentModel`) and may run a different model or thinking level than the parent. `scout` is read-only; `coder` and `reviewer` use the shared workspace unless overridden. Choose a specialist for its role, not its name.",
145
+ "",
146
+ "Fresh built-ins inherit the parent's exact model object and thinking level. A same-named Markdown file can override any built-in (first definition wins); an explicit `model` or `thinking` in that file replaces parent inheritance. Task-level `model`/`thinking`/`tools` always win. For `scout`/`coder`/`reviewer`, delegate.json overrides (`agentOverrides`, `agentOverridesByParentModel`) win over the Markdown file; `default` ignores overrides and uses only an explicit Markdown `model`/`thinking` when present. A prompt-only Markdown override keeps the built-in's tools and workspace, so `scout` stays read-only and `reviewer` stays shared unless the file explicitly changes them. Parent extension/MCP tools are not copied. Parent-global `AGENTS.md` instructions are also excluded. Project-local context and skills are rebuilt for the task's `cwd`; per-task fields remain explicit overrides.",
144
147
  "",
145
148
  "## Available Custom Agents",
146
149
  "",
@@ -166,6 +169,12 @@ export function getSubagentManualMarkdown(
166
169
  "",
167
170
  schemaTable(delegateArgumentsSchema.properties),
168
171
  "",
172
+ "## Pause and Resume",
173
+ "",
174
+ 'Use `delegate({ ticketAction: "pause", ticket: "<id>" })` to pause an async batch, and `ticketAction: "resume"` to continue its same live sessions. These controls return a snapshot immediately.',
175
+ "Pause finishes each current model response and its tool calls, then blocks before the next model request. Not-yet-started tasks stay queued. `pausing` means an operation is still finishing; `paused` means all remaining work has reached a checkpoint. A task that finishes naturally need not pause.",
176
+ "The inactivity watchdog is suspended at a paused turn boundary, but explicit wall-clock `deadlineMs` budgets continue. Wait does not resume. Cancel and shutdown still abort paused work. Live sessions, concurrency slots, workspace reservations, and scratch/isolated workspaces remain held; pause does not survive Pi exit or reload. Isolated preparation/application already in progress finishes before pausing; background processes are not frozen and files may be unfinished.",
177
+ "",
169
178
  "## Session Reuse",
170
179
  "",
171
180
  "When `sessionId` is set, the subagent is kept alive in a pool for the duration of the pi session.",
@@ -227,6 +236,8 @@ export function getSubagentManualMarkdown(
227
236
  "",
228
237
  '- `delegate({ ticketAction: "poll" })` \u2014 list all tickets',
229
238
  '- `delegate({ ticketAction: "poll", ticket: "abc123" })` \u2014 take one progress snapshot',
239
+ '- `delegate({ ticketAction: "pause", ticket: "abc123" })` \u2014 pause between turns, not after the entire task',
240
+ '- `delegate({ ticketAction: "resume", ticket: "abc123" })` \u2014 continue the same live sessions',
230
241
  '- `delegate({ ticketAction: "wait", ticket: "abc123" })` \u2014 block until finished; omit `timeoutMs` when the result is needed this turn',
231
242
  '- `delegate({ ticketAction: "wait", ticket: "abc123", timeoutMs: 600000 })` \u2014 bounded wait; timeout includes the latest snapshot, so do not poll afterward',
232
243
  '- `delegate({ ticketAction: "cancel", ticket: "abc123" })` \u2014 preview activity and partial effects before cancelling',
@@ -234,16 +245,18 @@ export function getSubagentManualMarkdown(
234
245
  "",
235
246
  `See the field tables above for the full semantics. Max ${getMaxAsyncTickets()} concurrent async tickets.`,
236
247
  "Async results arrive as follow-up messages, so Pi cannot fold their usage into the parent session total; displayed task usage remains informational.",
248
+ 'Isolation supports sync or async one-shot `workspace: "isolated"` tasks. In async mode it prepares Git worktrees after returning the ticket, runs one-shot workers, then reconciles successful proposals in task order before settling. Cancellation never applies unfinished work; a completed proposal cancelled before source application is retained as a private ref and full patch.',
237
249
  "",
238
250
  "## Gotchas",
239
251
  "",
240
252
  "- Dispatch validation is batch-wide and runs before spawning: one invalid task rejects the call without starting its siblings.",
241
- '- Shared writers that overlap tasks in the same call or a running sync/async dispatch are rejected. Unknown tools count as mutating. Run them sequentially, use `workspace: "isolated"` for Git-backed ordered reconciliation, or use `workspace: "scratch"` when changes may be discarded.',
253
+ "- Shared writers overlapping within one call are serialized in task order (reported in the result); overlap with a running sync/async dispatch is rejected, so wait for it to finish. Unknown tools count as mutating.",
242
254
  "- `*` means read/write/edit/bash, not every tool. `grep`, `find`, and `ls` are valid explicit tools and are the `ro` preset.",
243
255
  '- `tasks` is an array. The tool recovers common stringified calls for compatibility, but canonical calls use `{ tasks: [{ prompt: "..." }] }`.',
244
- '- Prefer `agent: "default"` — the parent\'s live model/thinking/native tools/base prompt — for general tasks. Pick `scout`/`coder`/`reviewer` only when the role, tool limits, or scratch workspace fit; they follow delegate.json tiers and may not run the parent model. Omitting `agent` creates an ad-hoc task (parent model, full tools).',
245
- "- Omit `thinking` for named agents unless the user asks or the task clearly needs escalation — task-level `thinking` overrides every configured tier (`agentOverrides`, `agentOverridesByParentModel`, Markdown frontmatter, `:level` model suffix), so a casual value silently defeats the configured budget. Ad-hoc tasks default to the parent's thinking.",
246
- "- An ad-hoc task with no `tools` uses `*`; a named custom task uses its profile; a profile with no tools uses `*`.",
256
+ "- Use a built-in agent first: `default` for general work, `scout` for read-only investigation, `coder` for implementation, and `reviewer` for review. Omitting `agent` creates a custom inline task rather than selecting a default.",
257
+ "- Omit `model`, `thinking`, `tools`, and `workspace` for built-ins unless the user requests an override or the built-in default cannot satisfy a concrete requirement. These task fields replace configured policy.",
258
+ "- In particular, setting `thinking` overrides the agent's configured thinking budget. A casual effort value silently defeats that budget; leave it unset rather than estimating effort for every task.",
259
+ "- An inline task with no `tools` uses `*`; a named custom task uses its profile; a profile with no tools uses `*`.",
247
260
  "- Subagents inherit all skills discovered in their `cwd` (via AgentSession's resource loader). Per-task skill filtering is not supported — curate the cwd's skill set instead.",
248
261
  `- Sync \`delegate\` runs at most ${getMaxConcurrent()} tasks at once (the rest queue, not fail). Use \`async: true\` to move work to the background.`,
249
262
  "- `deadlineMs` is a per-task wall-clock budget measured from when the task starts running (after queuing). It requests cooperative abort and is not a hard kill; completed writes/commands remain. Omission disables the deadline.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bermudi/pi-delegate",
3
- "version": "0.1.18",
3
+ "version": "0.1.20",
4
4
  "description": "Delegate tool for the Pi coding agent.",
5
5
  "keywords": [
6
6
  "pi-package"
@@ -28,6 +28,7 @@
28
28
  "@earendil-works/pi-coding-agent": "^0.84.2",
29
29
  "@earendil-works/pi-tui": "^0.84.2",
30
30
  "@marcfargas/pi-test-harness": "^0.6.1",
31
+ "@types/bun": "^1.4.0",
31
32
  "esbuild": "^0.28.2",
32
33
  "prettier": "^3.9.6",
33
34
  "typescript": "^5.9.3"
package/parent-context.ts CHANGED
@@ -6,7 +6,7 @@ import {
6
6
  /** Render the active parent conversation as compact context for a subagent. */
7
7
  export function buildParentTranscript(
8
8
  entries: SessionEntry[],
9
- leafId: string | null,
9
+ leafId: string | null | undefined,
10
10
  ): string | null {
11
11
  try {
12
12
  const ctx = buildSessionContext(entries, leafId);
package/pause.ts ADDED
@@ -0,0 +1,81 @@
1
+ /** Cooperative, in-memory ticket pause. Active operations finish; participants
2
+ * park at a checkpoint before starting their next operation. This neither
3
+ * freezes child processes nor releases workspace reservations/concurrency. */
4
+ export type PauseState = "running" | "pausing" | "paused";
5
+
6
+ export class PauseController {
7
+ private requested = false;
8
+ private readonly active = new Set<number>();
9
+ private readonly parked = new Set<number>();
10
+ private readonly wake = new Set<() => void>();
11
+
12
+ constructor(private readonly onChange: () => void = () => {}) {}
13
+
14
+ get state(): PauseState {
15
+ if (!this.requested) return "running";
16
+ return [...this.active].every((index) => this.parked.has(index))
17
+ ? "paused"
18
+ : "pausing";
19
+ }
20
+
21
+ isParked(index: number): boolean {
22
+ return this.requested && this.parked.has(index);
23
+ }
24
+
25
+ private publish(): void {
26
+ try {
27
+ this.onChange();
28
+ } catch (error) {
29
+ console.error("[delegate] pause state notification failed", error);
30
+ }
31
+ }
32
+
33
+ pause(): void {
34
+ if (this.requested) return;
35
+ this.requested = true;
36
+ this.publish();
37
+ }
38
+
39
+ resume(): void {
40
+ if (!this.requested) return;
41
+ this.requested = false;
42
+ for (const wake of this.wake) wake();
43
+ this.publish();
44
+ }
45
+
46
+ enter(index: number): void {
47
+ this.active.add(index);
48
+ this.publish();
49
+ }
50
+
51
+ leave(index: number): void {
52
+ this.active.delete(index);
53
+ this.parked.delete(index);
54
+ this.publish();
55
+ }
56
+
57
+ /** Abort unblocks the checkpoint without clearing another task's pause.
58
+ * Callers still observe their signal and take their normal cancellation path. */
59
+ async checkpoint(index: number, signal?: AbortSignal): Promise<void> {
60
+ if (!this.requested || signal?.aborted) return;
61
+ this.parked.add(index);
62
+ this.publish();
63
+ try {
64
+ while (this.requested && !signal?.aborted) {
65
+ await new Promise<void>((resolve) => {
66
+ const wake = () => {
67
+ this.wake.delete(wake);
68
+ signal?.removeEventListener("abort", wake);
69
+ resolve();
70
+ };
71
+ this.wake.add(wake);
72
+ signal?.addEventListener("abort", wake, { once: true });
73
+ if (signal?.aborted) wake();
74
+ });
75
+ }
76
+ } finally {
77
+ this.parked.delete(index);
78
+ this.publish();
79
+ }
80
+ }
81
+ }