@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/README.md +64 -15
- package/agents.ts +1 -1
- package/assistant-preview.ts +31 -0
- package/browser-state.ts +250 -0
- package/browser.ts +334 -0
- package/concurrency.ts +7 -0
- package/delegate.ts +8 -0
- package/dispatch.ts +512 -163
- package/extension.ts +65 -28
- package/format.ts +27 -9
- package/host.ts +1 -1
- package/isolated-workspace.ts +154 -8
- package/lifecycle.ts +34 -20
- package/manual.ts +21 -8
- package/package.json +2 -1
- package/parent-context.ts +1 -1
- package/pause.ts +81 -0
- package/pool.ts +492 -428
- package/render-branches.ts +19 -5
- package/render-result.ts +7 -0
- package/runner.ts +55 -1
- package/runtime.ts +36 -0
- package/schema.ts +29 -18
- package/status.ts +38 -11
- package/task-resolution.ts +12 -9
- package/test-harness.ts +81 -0
- package/ticket-format.ts +29 -7
- package/tickets.ts +724 -576
- package/types.ts +33 -0
- package/workspace.ts +58 -27
package/lifecycle.ts
CHANGED
|
@@ -14,8 +14,7 @@ import type {
|
|
|
14
14
|
TaskRunEnv,
|
|
15
15
|
ToolActivity,
|
|
16
16
|
} from "./types.ts";
|
|
17
|
-
import
|
|
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:
|
|
68
|
+
override:
|
|
69
|
+
((sessionId: string, expectedSession: AgentSession) => boolean) | undefined,
|
|
70
|
+
runtime: DelegateRuntime = getDefaultDelegateRuntime(),
|
|
74
71
|
): void {
|
|
75
|
-
|
|
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
|
-
?
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
245
|
-
"- Omit `thinking` for
|
|
246
|
-
"-
|
|
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.
|
|
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
|
+
}
|