@maxiaochao/pi-toolkit 0.10.7 → 0.10.8
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/CHANGELOG.md +7 -0
- package/README.md +1 -1
- package/package.json +4 -3
- package/packages/herdr-subagents/CHANGELOG.md +7 -0
- package/packages/herdr-subagents/README.md +4 -2
- package/packages/herdr-subagents/TESTING.md +6 -1
- package/packages/herdr-subagents/extensions/herdr-subagents.ts +83 -19
- package/packages/herdr-subagents/src/batch-runner.ts +3 -1
- package/packages/herdr-subagents/src/dispatcher.ts +8 -1
- package/packages/herdr-subagents/tests/extension-contract.ts +198 -2
- package/packages/herdr-subagents/tests/run-tests.mjs +25 -0
- package/packages/scheduler/CHANGELOG.md +5 -0
- package/packages/scheduler/README.md +4 -2
- package/packages/scheduler/TESTING.md +1 -1
- package/packages/scheduler/docs/demo.gif +0 -0
- package/packages/scheduler/docs/screenshot.png +0 -0
- package/packages/scheduler/extensions/scheduler.ts +87 -14
- package/packages/scheduler/src/scheduler.ts +4 -2
- package/packages/scheduler/tests/scheduler-core.mjs +7 -0
- package/packages/scheduler/tests/scheduler-extension.mjs +38 -2
- package/packages/worktree/CHANGELOG.md +7 -0
- package/packages/worktree/README.md +18 -3
- package/packages/worktree/TESTING.md +5 -2
- package/packages/worktree/bin/pi-worktree +118 -23
- package/packages/worktree/bin/pi-worktree-completion.bash +2 -2
- package/packages/worktree/extensions/worktree.ts +173 -0
- package/packages/worktree/package.json +37 -8
- package/packages/worktree/skills/pi-worktree/SKILL.md +11 -6
- package/packages/worktree/tests/extension.test.mjs +114 -0
- package/packages/worktree/tests/run-tests.sh +86 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
## 2026-10-09 - v0.10.8
|
|
2
|
+
|
|
3
|
+
- bundle worktree v0.2.0: add current-session worktree switching for users, Agents, and SDK Harnesses
|
|
4
|
+
- Improve Scheduler with clear-all controls and a visible above-editor row for every active schedule, separate from the below-editor Herdr Fleet.
|
|
5
|
+
- Bundle the Worktree Extension with `/worktree start`, the `enter_worktree` Agent Tool, and the structured Harness workspace-switch protocol.
|
|
6
|
+
- Add the idempotent `pi-worktree prepare --json` machine interface so the CLI remains the single implementation for worktree creation and reuse.
|
|
7
|
+
|
|
1
8
|
## 2026-10-09 - v0.10.7
|
|
2
9
|
|
|
3
10
|
- bundle scheduler v0.1.0: add session-scoped interval prompts with Agent and user controls
|
package/README.md
CHANGED
|
@@ -35,7 +35,7 @@ Restart Pi after updating. The `todo` tool and widget will no longer load. `pi u
|
|
|
35
35
|
| [pi-codex-edit](packages/codex-edit/README.md) | Codex-harness `apply_patch` routing for selected GPT models | `pi install npm:@maxiaochao/pi-codex-edit` | `pi update npm:@maxiaochao/pi-codex-edit` |
|
|
36
36
|
| [pi-herdr-subagents](packages/herdr-subagents/README.md) | Interactive Herdr Pi Agents with bounded concurrency, direct navigation, and recovery | `pi install npm:@maxiaochao/pi-herdr-subagents` | `pi update npm:@maxiaochao/pi-herdr-subagents` |
|
|
37
37
|
| [pi-scheduler](packages/scheduler/README.md) | Session-scoped interval prompts with Agent and `/schedule` controls | `pi install npm:@maxiaochao/pi-scheduler` | `pi update npm:@maxiaochao/pi-scheduler` |
|
|
38
|
-
| [pi-worktree](packages/worktree/README.md) | Worktree CLI,
|
|
38
|
+
| [pi-worktree](packages/worktree/README.md) | Worktree CLI, current-session switching Extension, and agent skill | `pi install npm:@maxiaochao/pi-worktree` | `pi update npm:@maxiaochao/pi-worktree` |
|
|
39
39
|
|
|
40
40
|
Each child README owns its feature description, motivation or upstream alignment, examples, screenshots/demos, configuration, and test documentation.
|
|
41
41
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@maxiaochao/pi-toolkit",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.8",
|
|
4
4
|
"description": "Personal pi coding agent toolkit: scheduler, /btw, /cache_export, apply_patch, browser automation, and bundled theme/skills.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"workspaces": [
|
|
@@ -34,7 +34,8 @@
|
|
|
34
34
|
"./extensions/btw/index.ts",
|
|
35
35
|
"./packages/codex-edit/extensions/codex-edit.ts",
|
|
36
36
|
"./packages/herdr-subagents/extensions/herdr-subagents.ts",
|
|
37
|
-
"./packages/scheduler/extensions/scheduler.ts"
|
|
37
|
+
"./packages/scheduler/extensions/scheduler.ts",
|
|
38
|
+
"./packages/worktree/extensions/worktree.ts"
|
|
38
39
|
],
|
|
39
40
|
"skills": [
|
|
40
41
|
"./skills",
|
|
@@ -58,7 +59,7 @@
|
|
|
58
59
|
},
|
|
59
60
|
"scripts": {
|
|
60
61
|
"test": "node tests/package-layout.mjs && npm --prefix packages/todo test && npm --prefix packages/cache-export test && npm --prefix packages/codex-edit test && npm --prefix packages/herdr-subagents test && npm --prefix packages/scheduler test && npm --prefix packages/worktree test && node --experimental-strip-types extensions/btw/tests/history.mjs",
|
|
61
|
-
"load-test": "pi -ne -e ./packages/cache-export/extensions/cache-export.ts && pi -ne -e ./extensions/btw/index.ts && pi -ne -e ./packages/codex-edit/extensions/codex-edit.ts && pi -ne -e ./packages/herdr-subagents/extensions/herdr-subagents.ts && pi -ne -e ./packages/scheduler/extensions/scheduler.ts",
|
|
62
|
+
"load-test": "pi -ne -e ./packages/cache-export/extensions/cache-export.ts && pi -ne -e ./extensions/btw/index.ts && pi -ne -e ./packages/codex-edit/extensions/codex-edit.ts && pi -ne -e ./packages/herdr-subagents/extensions/herdr-subagents.ts && pi -ne -e ./packages/scheduler/extensions/scheduler.ts && pi -ne -e ./packages/worktree/extensions/worktree.ts",
|
|
62
63
|
"eval:agents": "node evaluations/agents-md/run.mjs",
|
|
63
64
|
"eval:agents:test": "node evaluations/agents-md/tests/run-tests.mjs",
|
|
64
65
|
"postinstall": "node scripts/install-agents.mjs"
|
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
## Unreleased
|
|
2
|
+
|
|
3
|
+
- Return a foreground concurrent batch as soon as one task fails, keep surviving siblings running under durable background ownership, and send exactly one final follow-up after the whole batch settles.
|
|
4
|
+
- Release the dispatcher slot at foreground failure handoff so later batches start immediately, while detached work remains cancellable, participates in shutdown, and cannot clear a newer active run when it settles.
|
|
5
|
+
- Preserve full-batch waiting for successful foreground runs and the immediate-return behavior of explicit background runs.
|
|
6
|
+
- Join every started batch worker before resolving its run promise, including when another worker's final state persistence fails.
|
|
7
|
+
|
|
1
8
|
## 2026-10-09 - v0.4.3
|
|
2
9
|
|
|
3
10
|
- add per-run status, neutral analyst persona, and tool-agnostic Agent Team protocol
|
|
@@ -64,7 +64,9 @@ herdr_subagents({
|
|
|
64
64
|
})
|
|
65
65
|
```
|
|
66
66
|
|
|
67
|
-
`tasks` is always a non-empty array: one item is a single task and multiple items form a batch. Set `background: true` for either form to return a stable run ID immediately while the queue continues.
|
|
67
|
+
`tasks` is always a non-empty array: one item is a single task and multiple items form a batch. Set `background: true` for either form to return a stable run ID immediately while the queue continues. The dispatcher starts batches FIFO, with one batch holding its active slot at a time; a failed foreground handoff releases that slot as described below. Each background run sends one compact parent notification only when the whole run completes, partially fails, fails, or blocks. Individual successful tasks do not wake the parent model.
|
|
68
|
+
|
|
69
|
+
A foreground concurrent batch normally waits for the whole batch. If any task fails while siblings are still running, the call immediately returns the stable `runId` and that failure's details, then durably continues the same batch in the background without cancelling its siblings. The handed-off batch releases its dispatcher slot immediately, so a later submission can start while its surviving tasks are still running. The detached batch remains cancellable and is still joined during session shutdown. Its final aggregate is delivered exactly once through the same follow-up notification used by an explicit background run.
|
|
68
70
|
|
|
69
71
|
Background work normally relies on that terminal notification. When the user asks for progress or the parent must confirm a review gate before continuing, query only that run's compact snapshot:
|
|
70
72
|
|
|
@@ -96,7 +98,7 @@ Read-only tasks start FIFO up to the configured concurrency limit. A pane is cre
|
|
|
96
98
|
|
|
97
99
|
The widget shows one row per queued, running, blocked, failed, or completed task. Answering a blocked task or resuming saved history refreshes authoritative persona, timing, session, status, and pane projections as work advances. Active duration accumulates only while the task is running, excluding time spent queued or waiting for an answer. Completed rows remain selectable until the whole batch settles. `/herdr-subagents active` shows the same compact state. Native labels stay short: `SA · <batch>` for the tab and `<order> · <task>` for each pane. When all tasks settle, the tab closes immediately if unfocused or after the user leaves it; a deferred close failure is persisted and shown as a warning. An already-missing tab is treated as successfully cleaned, so retries are idempotent.
|
|
98
100
|
|
|
99
|
-
Foreground calls
|
|
101
|
+
Foreground calls wait until all children report completion or a request for input when no task fails. On the first task failure, they return early as described above while the surviving children continue. Every child is started with Herdr's Agent lifecycle as a normal Pi TUI and prompted with wait semantics; no print-mode command or completion marker is injected into the pane.
|
|
100
102
|
|
|
101
103
|
## Reports and records
|
|
102
104
|
|
|
@@ -12,6 +12,11 @@
|
|
|
12
12
|
|
|
13
13
|
- Resolve package, global, and project personas and validate frontmatter/configuration, including the neutral read-only `analyst` persona.
|
|
14
14
|
- Run foreground tasks and FIFO bounded-concurrency batches through an injectable Herdr boundary.
|
|
15
|
+
- Simulate one fast failure beside a controlled slow success and verify the foreground call returns the stable run ID and failure before releasing the slow task, detaches caller cancellation without aborting that task, releases the dispatcher so the next batch starts before the slow task settles, removes durable queue state, and emits exactly one final follow-up.
|
|
16
|
+
- Verify a detached dispatcher promise can settle normally after its successor starts without clearing or otherwise disturbing the successor's active slot, while blocked foreground runs retain their existing dispatcher ownership.
|
|
17
|
+
- Let a detached run later block or be explicitly cancelled and verify the newer dispatcher owner remains intact and each detached run emits exactly one final notification.
|
|
18
|
+
- Shut down the parent while a handed-off sibling is active and verify it is cancelled and joined, durable queue state is removed, and no stale follow-up is sent.
|
|
19
|
+
- Verify all-success foreground batches still wait for every task and explicit `background: true` submissions still return immediately.
|
|
15
20
|
- Verify all paths use interactive Agent start/prompt without print mode, shell injection, markers, or pane reuse.
|
|
16
21
|
- Force write-capable batches to serial execution while allowing bounded read-only parallelism, including three concurrent `analyst` tasks with independent task-level models.
|
|
17
22
|
- Preserve compact reports, full results, Pi sessions, blocked questions, and historical follow-ups.
|
|
@@ -25,4 +30,4 @@
|
|
|
25
30
|
|
|
26
31
|
## Latest baseline
|
|
27
32
|
|
|
28
|
-
All 179 deterministic scheduler, recovery, process-boundary, schema-validation, navigation, metrics, responsive-formatting, and persona cases must pass. Release validation also requires extension loading and a real Herdr read-only batch smoke with distinct task panes and final tab cleanup. The latest smoke completed two concurrent tasks, retained its focused run tab through settlement, and closed it automatically after focus moved away.
|
|
33
|
+
All 179 deterministic scheduler, recovery, process-boundary, schema-validation, navigation, metrics, responsive-formatting, and persona cases plus the extension-contract lifecycle simulations must pass. Release validation also requires extension loading and a real Herdr read-only batch smoke with distinct task panes and final tab cleanup. The latest smoke completed two concurrent tasks, retained its focused run tab through settlement, and closed it automatically after focus moved away.
|
|
@@ -195,9 +195,9 @@ export function registerHerdrSubagents(pi: ExtensionAPI, dependencies: HerdrSuba
|
|
|
195
195
|
pi.registerTool({
|
|
196
196
|
name: "herdr_subagents",
|
|
197
197
|
label: "Herdr Subagents",
|
|
198
|
-
description: "Run one or more ordered tasks in visible interactive Herdr Pi Agents. Use one tasks item for a single task. Independent read-only tasks may run concurrently; write-capable runs are serialized. Foreground calls
|
|
198
|
+
description: "Run one or more ordered tasks in visible interactive Herdr Pi Agents. Use one tasks item for a single task. Independent read-only tasks may run concurrently; write-capable runs are serialized. Foreground calls wait for the batch unless a task fails, when the run continues in the background and returns its run ID immediately. Explicit background calls always return a run ID immediately. Complete results and Pi sessions stay in project-local records.",
|
|
199
199
|
promptSnippet: "Run focused work in visible Herdr subagents",
|
|
200
|
-
promptGuidelines: ["Call herdr_subagents_control with action=list once before the first execution. Submit an ordered tasks list; use one item for a single task and concurrency only for independent read-only tasks. Foreground calls
|
|
200
|
+
promptGuidelines: ["Call herdr_subagents_control with action=list once before the first execution. Submit an ordered tasks list; use one item for a single task and concurrency only for independent read-only tasks. Foreground calls wait for full success or blocking input, but return a run ID as soon as one task fails while surviving siblings continue in the background. Explicit background calls return a run ID immediately."],
|
|
201
201
|
parameters: HerdrSubagentsParams,
|
|
202
202
|
prepareArguments: normalizeHerdrSubagentsArguments,
|
|
203
203
|
|
|
@@ -205,35 +205,67 @@ export function registerHerdrSubagents(pi: ExtensionAPI, dependencies: HerdrSuba
|
|
|
205
205
|
const generation = sessionGeneration;
|
|
206
206
|
const runId = randomUUID();
|
|
207
207
|
const controller = new AbortController();
|
|
208
|
+
const foregroundController = new AbortController();
|
|
209
|
+
let foregroundAttached = !params.background;
|
|
210
|
+
const abortForeground = () => {
|
|
211
|
+
if (foregroundAttached && signal?.aborted) foregroundController.abort(signal.reason);
|
|
212
|
+
};
|
|
213
|
+
if (foregroundAttached) {
|
|
214
|
+
if (signal?.aborted) abortForeground();
|
|
215
|
+
else signal?.addEventListener("abort", abortForeground, { once: true });
|
|
216
|
+
}
|
|
208
217
|
runControllers.set(runId, controller);
|
|
209
|
-
const runSignal = AbortSignal.any([controller.signal, sessionController.signal, ...(!params.background
|
|
218
|
+
const runSignal = AbortSignal.any([controller.signal, sessionController.signal, ...(!params.background ? [foregroundController.signal] : [])]);
|
|
219
|
+
let resolveEarlyFailure: ((details: HerdrSubagentsBatchDetails) => void) | undefined;
|
|
220
|
+
const earlyFailure = new Promise<HerdrSubagentsBatchDetails>((resolve) => { resolveEarlyFailure = resolve; });
|
|
221
|
+
let failureObserved = false;
|
|
222
|
+
let runSettled = false;
|
|
210
223
|
const executeRun = async () => {
|
|
211
224
|
const result = await execute(params, runSignal, (update) => {
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
225
|
+
const details = update.details as HerdrSubagentsBatchDetails | undefined;
|
|
226
|
+
if (details?.activity) fleet.sync(details);
|
|
227
|
+
const hasUnfinishedSibling = details?.activity?.some((task) => task.status === "queued" || task.status === "running");
|
|
228
|
+
if (!params.background && !failureObserved && details?.effectiveConcurrency > 1 && hasUnfinishedSibling && details.tasks?.some((task) => task.status === "failed")) {
|
|
229
|
+
failureObserved = true;
|
|
230
|
+
foregroundAttached = false;
|
|
231
|
+
signal?.removeEventListener("abort", abortForeground);
|
|
232
|
+
resolveEarlyFailure?.(details);
|
|
233
|
+
}
|
|
234
|
+
const updateText = update.content.find((item) => item.type === "text")?.text;
|
|
235
|
+
const updateDetails = update.details as { warning?: string };
|
|
236
|
+
if (updateDetails.warning || updateText?.includes("cleanup failed") || (!foregroundAttached && updateText?.includes("appears stalled"))) ctx.ui.notify(updateDetails.warning ?? updateText ?? "Subagent warning", "warning");
|
|
237
|
+
if (foregroundAttached) onUpdate?.(update);
|
|
218
238
|
}, ctx, { agentsDirectory: packageAgentsDir, owner: { sessionId: ownerSessionId, processId: process.pid }, runId });
|
|
219
239
|
fleet.sync(result.details);
|
|
220
240
|
return result;
|
|
221
241
|
};
|
|
222
242
|
if (params.background) await persistQueuedRun(ctx.cwd, runId, params, { sessionId: ownerSessionId, processId: process.pid });
|
|
223
243
|
const promise = dispatcher.submit(runId, async () => {
|
|
224
|
-
const result = await executeRun()
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
244
|
+
const result = await executeRun();
|
|
245
|
+
const details = result.details as HerdrSubagentsBatchDetails;
|
|
246
|
+
const ownsDispatcher = dispatcher.snapshot().activeRunId === runId;
|
|
247
|
+
if (ownsDispatcher) fleet.setActive(details.status === "blocked" ? details : undefined);
|
|
248
|
+
else if (details.status === "blocked") fleet.clearRun(runId);
|
|
249
|
+
if (ownsDispatcher && fleet.active) {
|
|
250
|
+
dispatcher.retain(runId);
|
|
229
251
|
}
|
|
230
252
|
return result;
|
|
231
253
|
});
|
|
232
254
|
runPromises.set(runId, promise);
|
|
233
255
|
inFlight.add(promise);
|
|
234
|
-
void promise.finally(() => {
|
|
235
|
-
|
|
236
|
-
|
|
256
|
+
void promise.finally(() => {
|
|
257
|
+
runSettled = true;
|
|
258
|
+
foregroundAttached = false;
|
|
259
|
+
signal?.removeEventListener("abort", abortForeground);
|
|
260
|
+
inFlight.delete(promise);
|
|
261
|
+
runControllers.delete(runId);
|
|
262
|
+
runPromises.delete(runId);
|
|
263
|
+
}).catch(() => undefined);
|
|
264
|
+
let backgroundLifecycleStarted = false;
|
|
265
|
+
const startBackgroundLifecycle = () => {
|
|
266
|
+
if (backgroundLifecycleStarted) return;
|
|
267
|
+
backgroundLifecycleStarted = true;
|
|
268
|
+
const lifecycle = promise.then((result) => {
|
|
237
269
|
if (generation !== sessionGeneration) return;
|
|
238
270
|
notifyRun(runId, result);
|
|
239
271
|
if (!fleet.active && !dispatcher.snapshot().activeRunId && dispatcher.snapshot().queuedRunIds.length === 0) fleet.render();
|
|
@@ -244,10 +276,42 @@ export function registerHerdrSubagents(pi: ExtensionAPI, dependencies: HerdrSuba
|
|
|
244
276
|
return;
|
|
245
277
|
}
|
|
246
278
|
pi.sendMessage({ customType: "herdr-subagents-run", content: `Subagent run ${runId} failed: ${error instanceof Error ? error.message : String(error)}`, display: true, details: { runId, status: "failed" } }, { triggerTurn: true, deliverAs: "followUp" });
|
|
247
|
-
});
|
|
279
|
+
}).finally(() => removeQueuedRun(ctx.cwd, runId));
|
|
280
|
+
inFlight.add(lifecycle);
|
|
281
|
+
void lifecycle.finally(() => inFlight.delete(lifecycle)).catch(() => undefined);
|
|
282
|
+
};
|
|
283
|
+
if (params.background) {
|
|
284
|
+
startBackgroundLifecycle();
|
|
248
285
|
return { content: [{ type: "text" as const, text: `Subagent run queued: ${runId}` }], details: { runId, status: "queued", background: true } };
|
|
249
286
|
}
|
|
250
|
-
const
|
|
287
|
+
const foregroundOutcome = await Promise.race([
|
|
288
|
+
promise.then((result) => ({ kind: "settled" as const, result })),
|
|
289
|
+
earlyFailure.then((details) => ({ kind: "failed" as const, details })),
|
|
290
|
+
]);
|
|
291
|
+
if (foregroundOutcome.kind === "failed") {
|
|
292
|
+
try {
|
|
293
|
+
await persistQueuedRun(ctx.cwd, runId, params, { sessionId: ownerSessionId, processId: process.pid });
|
|
294
|
+
} catch (error) {
|
|
295
|
+
controller.abort(new Error("Could not persist failed foreground run for background continuation."));
|
|
296
|
+
await promise.catch(() => undefined);
|
|
297
|
+
await removeQueuedRun(ctx.cwd, runId).catch(() => undefined);
|
|
298
|
+
throw error;
|
|
299
|
+
}
|
|
300
|
+
if (runSettled) {
|
|
301
|
+
await removeQueuedRun(ctx.cwd, runId);
|
|
302
|
+
return promise;
|
|
303
|
+
}
|
|
304
|
+
dispatcher.detach(runId);
|
|
305
|
+
startBackgroundLifecycle();
|
|
306
|
+
const failedTask = foregroundOutcome.details.tasks.find((task) => task.status === "failed");
|
|
307
|
+
const failure = failedTask?.error ?? failedTask?.summary ?? "Unknown task failure.";
|
|
308
|
+
return {
|
|
309
|
+
content: [{ type: "text" as const, text: `Subagent run ${runId} continues in the background after ${failedTask?.name ?? "a task"} failed: ${failure}` }],
|
|
310
|
+
details: { ...foregroundOutcome.details, runId, background: true, earlyReturn: true, failedTask: failedTask ? { index: failedTask.index, name: failedTask.name, summary: failedTask.summary, ...(failedTask.error ? { error: failedTask.error } : {}) } : undefined },
|
|
311
|
+
isError: true,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
const result = foregroundOutcome.result;
|
|
251
315
|
if (!fleet.active && !dispatcher.snapshot().activeRunId && dispatcher.snapshot().queuedRunIds.length === 0) fleet.resetSelection();
|
|
252
316
|
return result;
|
|
253
317
|
},
|
|
@@ -176,7 +176,9 @@ export async function runHerdrSubagentsBatch(tasks: BatchTask[], options: BatchO
|
|
|
176
176
|
if (results[index]?.status === "blocked") return;
|
|
177
177
|
}
|
|
178
178
|
};
|
|
179
|
-
await Promise.
|
|
179
|
+
const workers = await Promise.allSettled(Array.from({ length: effectiveConcurrency }, worker));
|
|
180
|
+
const rejectedWorker = workers.find((worker): worker is PromiseRejectedResult => worker.status === "rejected");
|
|
181
|
+
if (rejectedWorker) throw rejectedWorker.reason;
|
|
180
182
|
if (options.signal?.aborted) {
|
|
181
183
|
for (let index = 0; index < tasks.length; index += 1) {
|
|
182
184
|
if (activity[index].status !== "queued") continue;
|
|
@@ -37,6 +37,13 @@ export class RunDispatcher {
|
|
|
37
37
|
this.retainedRunId = id;
|
|
38
38
|
this.changed();
|
|
39
39
|
}
|
|
40
|
+
detach(id: string): boolean {
|
|
41
|
+
if (this.activeRunId !== id || this.retainedRunId === id) return false;
|
|
42
|
+
this.activeRunId = undefined;
|
|
43
|
+
this.changed();
|
|
44
|
+
void this.drain();
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
40
47
|
release(id: string): boolean {
|
|
41
48
|
if (this.activeRunId !== id || this.retainedRunId !== id) return false;
|
|
42
49
|
this.retainedRunId = undefined;
|
|
@@ -71,7 +78,7 @@ export class RunDispatcher {
|
|
|
71
78
|
try { run.resolve(await run.execute()); }
|
|
72
79
|
catch (error) { run.reject(error); }
|
|
73
80
|
finally {
|
|
74
|
-
if (this.retainedRunId !== run.id) {
|
|
81
|
+
if (this.activeRunId === run.id && this.retainedRunId !== run.id) {
|
|
75
82
|
this.activeRunId = undefined;
|
|
76
83
|
this.changed();
|
|
77
84
|
void this.drain();
|
|
@@ -16,6 +16,8 @@ function harness(execute: (...args: any[]) => Promise<any>) {
|
|
|
16
16
|
const editor = { kind: "editor" };
|
|
17
17
|
const focusedPanes: string[] = [];
|
|
18
18
|
const notifications: Array<{ message: string; level?: string }> = [];
|
|
19
|
+
const messages: Array<{ message: any; options: any }> = [];
|
|
20
|
+
const messageWaiters: Array<{ predicate: (message: any) => boolean; resolve: () => void }> = [];
|
|
19
21
|
const ui = {
|
|
20
22
|
setWidget(_id: string, widget: unknown) {
|
|
21
23
|
widgetCalls += 1;
|
|
@@ -32,7 +34,13 @@ function harness(execute: (...args: any[]) => Promise<any>) {
|
|
|
32
34
|
if (definition.name === "herdr_subagents_control") controlTool = definition;
|
|
33
35
|
},
|
|
34
36
|
registerCommand(name: string) { commands.push(name); },
|
|
35
|
-
sendMessage() {
|
|
37
|
+
sendMessage(message: any, options: any) {
|
|
38
|
+
messages.push({ message, options });
|
|
39
|
+
for (const waiter of messageWaiters.splice(0)) {
|
|
40
|
+
if (waiter.predicate(message)) waiter.resolve();
|
|
41
|
+
else messageWaiters.push(waiter);
|
|
42
|
+
}
|
|
43
|
+
},
|
|
36
44
|
};
|
|
37
45
|
registerHerdrSubagents(pi as never, {
|
|
38
46
|
herdr: { async focusPane(paneId: string) { focusedPanes.push(paneId); } } as never,
|
|
@@ -51,6 +59,11 @@ function harness(execute: (...args: any[]) => Promise<any>) {
|
|
|
51
59
|
get renderedLines() { return renderedLines; },
|
|
52
60
|
focusedPanes,
|
|
53
61
|
notifications,
|
|
62
|
+
messages,
|
|
63
|
+
waitForMessage(predicate: (message: any) => boolean) {
|
|
64
|
+
if (messages.some(({ message }) => predicate(message))) return Promise.resolve();
|
|
65
|
+
return new Promise<void>((resolve) => messageWaiters.push({ predicate, resolve }));
|
|
66
|
+
},
|
|
54
67
|
ui,
|
|
55
68
|
};
|
|
56
69
|
}
|
|
@@ -64,6 +77,188 @@ async function startHarness(instance: ReturnType<typeof harness>, cwd: string) {
|
|
|
64
77
|
});
|
|
65
78
|
}
|
|
66
79
|
|
|
80
|
+
function deferred<T>() {
|
|
81
|
+
let resolve!: (value: T) => void;
|
|
82
|
+
const promise = new Promise<T>((settle) => { resolve = settle; });
|
|
83
|
+
return { promise, resolve };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function waitUntil(predicate: () => boolean, message: string) {
|
|
87
|
+
for (let attempt = 0; attempt < 100; attempt += 1) {
|
|
88
|
+
if (predicate()) return;
|
|
89
|
+
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
90
|
+
}
|
|
91
|
+
throw new Error(message);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function verifyForegroundFailureHandoff() {
|
|
95
|
+
const cwd = mkdtempSync(join(tmpdir(), "herdr-early-failure-"));
|
|
96
|
+
try {
|
|
97
|
+
const slow = deferred<void>();
|
|
98
|
+
let slowSignal: AbortSignal | undefined;
|
|
99
|
+
let secondStarted = false;
|
|
100
|
+
let callCount = 0;
|
|
101
|
+
const caller = new AbortController();
|
|
102
|
+
const failedTask = { index: 0, name: "fast failure", status: "failed", summary: "failed quickly", error: "boom", documents: [], paneId: "w1:p1", recordDirectory: "task-1", sessionFile: "task-1/session.jsonl" };
|
|
103
|
+
const slowTask = { index: 1, name: "slow success", status: "completed", summary: "finished slowly", documents: [], paneId: "w1:p2", recordDirectory: "task-2", sessionFile: "task-2/session.jsonl" };
|
|
104
|
+
const instance = harness(async (_params, signal, onUpdate, _ctx, dependencies) => {
|
|
105
|
+
callCount += 1;
|
|
106
|
+
if (callCount > 1) {
|
|
107
|
+
secondStarted = true;
|
|
108
|
+
return { content: [{ type: "text", text: "second done" }], details: { runId: dependencies.runId, label: "second", status: "completed", summary: "done", documents: [], tasks: [], activity: [] } };
|
|
109
|
+
}
|
|
110
|
+
slowSignal = signal;
|
|
111
|
+
onUpdate?.({
|
|
112
|
+
content: [{ type: "text", text: "1/2 tasks settled." }],
|
|
113
|
+
details: { runId: dependencies.runId, label: "handoff", status: "running", summary: "1/2 tasks settled.", documents: [], tasks: [failedTask], activity: [{ index: 0, name: failedTask.name, status: "failed" }, { index: 1, name: slowTask.name, status: "running" }], effectiveConcurrency: 2, requestedConcurrency: 2 },
|
|
114
|
+
});
|
|
115
|
+
queueMicrotask(() => caller.abort(new Error("foreground tool call ended")));
|
|
116
|
+
await slow.promise;
|
|
117
|
+
return { content: [{ type: "text", text: "1/2 tasks completed; 1 failed." }], details: { runId: dependencies.runId, label: "handoff", status: "partial", summary: "1/2 tasks completed; 1 failed.", documents: [], tasks: [failedTask, slowTask], activity: [{ index: 0, name: failedTask.name, status: "failed" }, { index: 1, name: slowTask.name, status: "completed" }], effectiveConcurrency: 2, requestedConcurrency: 2 }, isError: true };
|
|
118
|
+
});
|
|
119
|
+
await startHarness(instance, cwd);
|
|
120
|
+
const foreground = instance.tool?.execute("handoff", { concurrency: 2, tasks: [{ name: failedTask.name, prompt: "fail" }, { name: slowTask.name, prompt: "wait" }] }, caller.signal, undefined, { cwd, ui: instance.ui });
|
|
121
|
+
const early = await Promise.race([foreground, new Promise((_, reject) => setTimeout(() => reject(new Error("foreground did not return after the first task failed")), 100))]);
|
|
122
|
+
if (!early.details?.runId || early.details?.earlyReturn !== true || early.details?.background !== true || early.details?.failedTask?.error !== "boom" || !early.content?.[0]?.text.includes("boom")) throw new Error("early foreground result omitted the run ID or failure details");
|
|
123
|
+
if (slowSignal?.aborted) throw new Error("early foreground handoff cancelled the still-running sibling");
|
|
124
|
+
const queuedAfterHandoff = readdirSync(join(cwd, ".pi", "herdr-subagents", "queue")).filter((name) => name.endsWith(".json"));
|
|
125
|
+
if (queuedAfterHandoff.length !== 1) throw new Error("early foreground handoff was not persisted as background work");
|
|
126
|
+
const second = instance.tool?.execute("second", { background: true, tasks: [{ name: "second", prompt: "later" }] }, undefined, undefined, { cwd, ui: instance.ui });
|
|
127
|
+
await second;
|
|
128
|
+
await waitUntil(() => secondStarted, "dispatcher did not start the next batch after the failed foreground batch detached");
|
|
129
|
+
if (slowSignal?.aborted) throw new Error("starting the next batch cancelled the detached slow sibling");
|
|
130
|
+
const handoffNotified = instance.waitForMessage((message) => message.details?.runId === early.details.runId);
|
|
131
|
+
slow.resolve();
|
|
132
|
+
await handoffNotified;
|
|
133
|
+
if (slowSignal?.aborted) throw new Error("detached slow sibling was cancelled before it finished");
|
|
134
|
+
const handoffMessages = instance.messages.filter(({ message }) => message.details?.runId === early.details.runId);
|
|
135
|
+
if (handoffMessages.length !== 1 || handoffMessages[0].options?.deliverAs !== "followUp") throw new Error("handed-off run did not send exactly one final follow-up");
|
|
136
|
+
await waitUntil(() => !readdirSync(join(cwd, ".pi", "herdr-subagents", "queue")).some((name) => name.startsWith(early.details.runId)), "settled handed-off run left its durable queue record behind");
|
|
137
|
+
|
|
138
|
+
const blockedRoot = join(cwd, "blocked-handoff");
|
|
139
|
+
mkdirSync(blockedRoot, { recursive: true });
|
|
140
|
+
const becomeBlocked = deferred<void>();
|
|
141
|
+
const finishSuccessor = deferred<void>();
|
|
142
|
+
let blockedCalls = 0;
|
|
143
|
+
let successorStarted = false;
|
|
144
|
+
let thirdStarted = false;
|
|
145
|
+
const blockedTask = { index: 1, name: "needs input", status: "blocked", summary: "waiting for an answer", question: "Continue?", documents: [], paneId: "w1:p2", recordDirectory: "task-2", sessionFile: "task-2/session.jsonl" };
|
|
146
|
+
const blockedHandoff = harness(async (_params, _signal, onUpdate, _ctx, dependencies) => {
|
|
147
|
+
blockedCalls += 1;
|
|
148
|
+
if (blockedCalls === 2) {
|
|
149
|
+
successorStarted = true;
|
|
150
|
+
await finishSuccessor.promise;
|
|
151
|
+
return { content: [{ type: "text", text: "successor done" }], details: { runId: dependencies.runId, label: "successor", status: "completed", summary: "done", documents: [], tasks: [], activity: [] } };
|
|
152
|
+
}
|
|
153
|
+
if (blockedCalls === 3) {
|
|
154
|
+
thirdStarted = true;
|
|
155
|
+
return { content: [{ type: "text", text: "third done" }], details: { runId: dependencies.runId, label: "third", status: "completed", summary: "done", documents: [], tasks: [], activity: [] } };
|
|
156
|
+
}
|
|
157
|
+
onUpdate?.({
|
|
158
|
+
content: [{ type: "text", text: "1/2 tasks settled." }],
|
|
159
|
+
details: { runId: dependencies.runId, label: "blocks later", status: "running", summary: "1/2 tasks settled.", documents: [], tasks: [failedTask], activity: [{ index: 0, name: failedTask.name, status: "failed" }, { index: 1, name: blockedTask.name, status: "running" }], effectiveConcurrency: 2, requestedConcurrency: 2 },
|
|
160
|
+
});
|
|
161
|
+
await becomeBlocked.promise;
|
|
162
|
+
return { content: [{ type: "text", text: "run blocked" }], details: { runId: dependencies.runId, label: "blocks later", status: "blocked", summary: "waiting for input", documents: [], tasks: [failedTask, blockedTask], activity: [{ index: 0, name: failedTask.name, status: "failed" }, { index: 1, name: blockedTask.name, status: "blocked" }], effectiveConcurrency: 2, requestedConcurrency: 2 } };
|
|
163
|
+
});
|
|
164
|
+
await startHarness(blockedHandoff, blockedRoot);
|
|
165
|
+
const blockedEarly = await blockedHandoff.tool?.execute("blocks-later", { concurrency: 2, tasks: [{ name: failedTask.name, prompt: "fail" }, { name: blockedTask.name, prompt: "block" }] }, undefined, undefined, { cwd: blockedRoot, ui: blockedHandoff.ui });
|
|
166
|
+
await blockedHandoff.tool?.execute("successor", { background: true, tasks: [{ name: "successor", prompt: "wait" }] }, undefined, undefined, { cwd: blockedRoot, ui: blockedHandoff.ui });
|
|
167
|
+
await waitUntil(() => successorStarted, "successor did not start while detached run was still active");
|
|
168
|
+
const blockedNotified = blockedHandoff.waitForMessage((message) => message.details?.runId === blockedEarly.details.runId);
|
|
169
|
+
becomeBlocked.resolve();
|
|
170
|
+
await blockedNotified;
|
|
171
|
+
await blockedHandoff.tool?.execute("third", { background: true, tasks: [{ name: "third", prompt: "later" }] }, undefined, undefined, { cwd: blockedRoot, ui: blockedHandoff.ui });
|
|
172
|
+
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
173
|
+
if (thirdStarted) throw new Error("detached blocked settlement cleared the newer active dispatcher slot");
|
|
174
|
+
if (blockedHandoff.messages.filter(({ message }) => message.details?.runId === blockedEarly.details.runId).length !== 1) throw new Error("detached run that later blocked did not notify exactly once");
|
|
175
|
+
finishSuccessor.resolve();
|
|
176
|
+
await waitUntil(() => thirdStarted, "queued run did not start after the newer active run settled");
|
|
177
|
+
|
|
178
|
+
const cancelRoot = join(cwd, "cancel-handoff");
|
|
179
|
+
mkdirSync(cancelRoot, { recursive: true });
|
|
180
|
+
let detachedSignal: AbortSignal | undefined;
|
|
181
|
+
const cancelledHandoff = harness(async (params, signal, onUpdate, _ctx, dependencies) => {
|
|
182
|
+
if (params.action === "cancel") {
|
|
183
|
+
const result = await dependencies.cancelRun(params.runId);
|
|
184
|
+
return { content: [{ type: "text", text: "cancelled" }], details: { action: "cancel", runId: params.runId, ...result } };
|
|
185
|
+
}
|
|
186
|
+
detachedSignal = signal;
|
|
187
|
+
onUpdate?.({
|
|
188
|
+
content: [{ type: "text", text: "1/2 tasks settled." }],
|
|
189
|
+
details: { runId: dependencies.runId, label: "cancel detached", status: "running", summary: "1/2 tasks settled.", documents: [], tasks: [failedTask], activity: [{ index: 0, name: failedTask.name, status: "failed" }, { index: 1, name: slowTask.name, status: "running" }], effectiveConcurrency: 2, requestedConcurrency: 2 },
|
|
190
|
+
});
|
|
191
|
+
await new Promise<void>((_resolve, reject) => {
|
|
192
|
+
if (signal.aborted) reject(signal.reason);
|
|
193
|
+
else signal.addEventListener("abort", () => reject(signal.reason), { once: true });
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
await startHarness(cancelledHandoff, cancelRoot);
|
|
197
|
+
const cancelEarly = await cancelledHandoff.tool?.execute("cancel-detached", { concurrency: 2, tasks: [{ name: failedTask.name, prompt: "fail" }, { name: slowTask.name, prompt: "wait" }] }, undefined, undefined, { cwd: cancelRoot, ui: cancelledHandoff.ui });
|
|
198
|
+
const cancelledNotice = cancelledHandoff.waitForMessage((message) => message.details?.runId === cancelEarly.details.runId);
|
|
199
|
+
const cancelResult = await cancelledHandoff.controlTool?.execute("cancel", { action: "cancel", runId: cancelEarly.details.runId }, undefined, undefined, { cwd: cancelRoot, ui: cancelledHandoff.ui });
|
|
200
|
+
await cancelledNotice;
|
|
201
|
+
if (!detachedSignal?.aborted || cancelResult?.details?.cancelled !== true) throw new Error("cancel did not abort the detached handed-off run");
|
|
202
|
+
if (cancelledHandoff.messages.filter(({ message }) => message.details?.runId === cancelEarly.details.runId).length !== 1) throw new Error("cancelled detached run did not notify exactly once");
|
|
203
|
+
|
|
204
|
+
const shutdownRoot = join(cwd, "shutdown-handoff");
|
|
205
|
+
mkdirSync(shutdownRoot, { recursive: true });
|
|
206
|
+
let shutdownSignal: AbortSignal | undefined;
|
|
207
|
+
const shutdownHandoff = harness(async (_params, signal, onUpdate, _ctx, dependencies) => {
|
|
208
|
+
shutdownSignal = signal;
|
|
209
|
+
onUpdate?.({
|
|
210
|
+
content: [{ type: "text", text: "1/2 tasks settled." }],
|
|
211
|
+
details: { runId: dependencies.runId, label: "shutdown", status: "running", summary: "1/2 tasks settled.", documents: [], tasks: [failedTask], activity: [{ index: 0, name: failedTask.name, status: "failed" }, { index: 1, name: slowTask.name, status: "running" }], effectiveConcurrency: 2, requestedConcurrency: 2 },
|
|
212
|
+
});
|
|
213
|
+
await new Promise<void>((resolve) => signal.addEventListener("abort", () => resolve(), { once: true }));
|
|
214
|
+
return { content: [{ type: "text", text: "cancelled" }], details: { runId: dependencies.runId, label: "shutdown", status: "cancelled", summary: "cancelled", documents: [], tasks: [failedTask], activity: [{ index: 0, name: failedTask.name, status: "failed" }, { index: 1, name: slowTask.name, status: "cancelled" }], effectiveConcurrency: 2, requestedConcurrency: 2 }, isError: true };
|
|
215
|
+
});
|
|
216
|
+
await startHarness(shutdownHandoff, shutdownRoot);
|
|
217
|
+
const shutdownEarly = await shutdownHandoff.tool?.execute("shutdown", { concurrency: 2, tasks: [{ name: failedTask.name, prompt: "fail" }, { name: slowTask.name, prompt: "wait" }] }, undefined, undefined, { cwd: shutdownRoot, ui: shutdownHandoff.ui });
|
|
218
|
+
await Promise.race([
|
|
219
|
+
shutdownHandoff.handlers.get("session_shutdown")?.({}, { cwd: shutdownRoot, ui: shutdownHandoff.ui }),
|
|
220
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error("session shutdown leaked a handed-off run")), 100)),
|
|
221
|
+
]);
|
|
222
|
+
if (!shutdownSignal?.aborted) throw new Error("session shutdown did not abort the active handed-off sibling");
|
|
223
|
+
if (shutdownHandoff.messages.some(({ message }) => message.details?.runId === shutdownEarly.details.runId)) throw new Error("handed-off run notified after its parent session shut down");
|
|
224
|
+
const shutdownQueue = join(shutdownRoot, ".pi", "herdr-subagents", "queue");
|
|
225
|
+
if (existsSync(shutdownQueue) && readdirSync(shutdownQueue).some((name) => name.endsWith(".json"))) throw new Error("session shutdown left a handed-off queue record behind");
|
|
226
|
+
|
|
227
|
+
const successGate = deferred<void>();
|
|
228
|
+
const successStarted = deferred<void>();
|
|
229
|
+
const allSuccess = harness(async (_params, _signal, _onUpdate, _ctx, dependencies) => {
|
|
230
|
+
successStarted.resolve();
|
|
231
|
+
await successGate.promise;
|
|
232
|
+
return { content: [{ type: "text", text: "all done" }], details: { runId: dependencies.runId, label: "success", status: "completed", summary: "2/2 tasks completed.", documents: [], tasks: [], activity: [], effectiveConcurrency: 2, requestedConcurrency: 2 } };
|
|
233
|
+
});
|
|
234
|
+
await startHarness(allSuccess, cwd);
|
|
235
|
+
let successSettled = false;
|
|
236
|
+
const successPromise = allSuccess.tool?.execute("success", { concurrency: 2, tasks: [{ name: "one", prompt: "one" }, { name: "two", prompt: "two" }] }, undefined, undefined, { cwd, ui: allSuccess.ui }).then((result: any) => { successSettled = true; return result; });
|
|
237
|
+
await successStarted.promise;
|
|
238
|
+
if (successSettled) throw new Error("all-success foreground batch returned before the full batch settled");
|
|
239
|
+
successGate.resolve();
|
|
240
|
+
await successPromise;
|
|
241
|
+
|
|
242
|
+
const backgroundGate = deferred<void>();
|
|
243
|
+
const explicitBackground = harness(async (_params, _signal, _onUpdate, _ctx, dependencies) => {
|
|
244
|
+
await backgroundGate.promise;
|
|
245
|
+
return { content: [{ type: "text", text: "background done" }], details: { runId: dependencies.runId, label: "background", status: "completed", summary: "done", documents: [], tasks: [], activity: [], effectiveConcurrency: 1, requestedConcurrency: 1 } };
|
|
246
|
+
});
|
|
247
|
+
await startHarness(explicitBackground, cwd);
|
|
248
|
+
const queued = await Promise.race([
|
|
249
|
+
explicitBackground.tool?.execute("background", { background: true, tasks: [{ name: "background", prompt: "wait" }] }, undefined, undefined, { cwd, ui: explicitBackground.ui }),
|
|
250
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error("explicit background run stopped returning immediately")), 100)),
|
|
251
|
+
]);
|
|
252
|
+
if (queued.details?.background !== true || queued.details?.status !== "queued") throw new Error("explicit background result contract regressed");
|
|
253
|
+
const backgroundNotified = explicitBackground.waitForMessage((message) => message.details?.runId === queued.details.runId);
|
|
254
|
+
backgroundGate.resolve();
|
|
255
|
+
await backgroundNotified;
|
|
256
|
+
if (explicitBackground.messages.filter(({ message }) => message.details?.runId === queued.details.runId).length !== 1) throw new Error("explicit background run did not send one final follow-up");
|
|
257
|
+
} finally {
|
|
258
|
+
rmSync(cwd, { recursive: true, force: true });
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
67
262
|
async function verifyPublicUiWiring() {
|
|
68
263
|
const cwd = mkdtempSync(join(tmpdir(), "herdr-extension-contract-"));
|
|
69
264
|
try {
|
|
@@ -194,6 +389,7 @@ try {
|
|
|
194
389
|
if (child.tool || child.commands.length || child.handlers.size) throw new Error("Subagents registered inside a child Agent");
|
|
195
390
|
delete process.env.PI_HERDR_SUBAGENTS_CHILD;
|
|
196
391
|
await verifyPublicUiWiring();
|
|
392
|
+
await verifyForegroundFailureHandoff();
|
|
197
393
|
} finally {
|
|
198
394
|
if (originalHerdrEnv === undefined) delete process.env.HERDR_ENV;
|
|
199
395
|
else process.env.HERDR_ENV = originalHerdrEnv;
|
|
@@ -255,7 +451,7 @@ export default function (_pi: ExtensionAPI) {
|
|
|
255
451
|
if (!controlActions?.includes("status")) throw new Error("herdr_subagents_control action enum must include status");
|
|
256
452
|
if (!/status/.test(controlSchema.properties.runId.description)) throw new Error("control runId description must document status");
|
|
257
453
|
if (!/Use status for one run/.test(controlTool.description)) throw new Error("control tool description must distinguish status from history");
|
|
258
|
-
if (!/Foreground calls
|
|
454
|
+
if (!/Foreground calls wait/.test(tool.description) || !/Explicit background calls always return a run ID immediately/.test(tool.description)) {
|
|
259
455
|
throw new Error("tool description does not distinguish foreground and background execution");
|
|
260
456
|
}
|
|
261
457
|
}
|
|
@@ -614,8 +614,33 @@ try {
|
|
|
614
614
|
const retainedFirst = retainedDispatcher.submit("blocked-run", async () => { retainedStarts.push("blocked-run"); retainedDispatcher.retain("blocked-run"); return "blocked"; });
|
|
615
615
|
const retainedSecond = retainedDispatcher.submit("queued-run", async () => { retainedStarts.push("queued-run"); return "completed"; });
|
|
616
616
|
check("blocked run retains dispatcher ownership after its call returns", await retainedFirst === "blocked" && retainedDispatcher.snapshot().activeRunId === "blocked-run" && retainedStarts.join(",") === "blocked-run" && retainedDispatcher.snapshot().queuedRunIds.join(",") === "queued-run");
|
|
617
|
+
check("dispatcher does not detach a retained blocked run", retainedDispatcher.detach("blocked-run") === false);
|
|
617
618
|
retainedDispatcher.release("blocked-run");
|
|
618
619
|
check("respond settlement releases dispatcher ownership to the queued run", await retainedSecond === "completed" && retainedStarts.join(",") === "blocked-run,queued-run");
|
|
620
|
+
const detachedDispatcher = new RunDispatcher();
|
|
621
|
+
let settleDetached;
|
|
622
|
+
let settleSuccessor;
|
|
623
|
+
let detachedSettled = false;
|
|
624
|
+
const detachedStarts = [];
|
|
625
|
+
const detachedFirst = detachedDispatcher.submit("detached-run", async () => {
|
|
626
|
+
detachedStarts.push("detached-run");
|
|
627
|
+
await new Promise((resolve) => { settleDetached = resolve; });
|
|
628
|
+
detachedSettled = true;
|
|
629
|
+
return "detached";
|
|
630
|
+
});
|
|
631
|
+
const afterDetached = detachedDispatcher.submit("after-detach", async () => {
|
|
632
|
+
detachedStarts.push("after-detach");
|
|
633
|
+
await new Promise((resolve) => { settleSuccessor = resolve; });
|
|
634
|
+
return "next";
|
|
635
|
+
});
|
|
636
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
637
|
+
const detached = detachedDispatcher.detach("detached-run");
|
|
638
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
639
|
+
check("dispatcher detaches an unsettled active run and starts its successor", detached && !detachedSettled && detachedStarts.join(",") === "detached-run,after-detach" && detachedDispatcher.snapshot().activeRunId === "after-detach");
|
|
640
|
+
settleDetached();
|
|
641
|
+
check("detached run still resolves normally without disturbing its active successor", await detachedFirst === "detached" && detachedDispatcher.snapshot().activeRunId === "after-detach");
|
|
642
|
+
settleSuccessor();
|
|
643
|
+
check("successor settles normally after detached predecessor", await afterDetached === "next" && detachedDispatcher.snapshot().activeRunId === undefined);
|
|
619
644
|
const backgroundId = "stable-background-id";
|
|
620
645
|
const stableHerdr = fakeHerdr();
|
|
621
646
|
const stable = await executeHerdrSubagents({ tasks: [{ name: "stable", prompt: "stable", agent: "explorer" }] }, undefined, undefined, { cwd: root, model: { provider: "fake", id: "model" } }, { agentsDirectory: join(process.cwd(), "agents"), herdr: stableHerdr, runId: backgroundId });
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
## Unreleased
|
|
2
|
+
|
|
3
|
+
- Add `clear` to the Agent tool and `/schedule clear` for stopping every active schedule.
|
|
4
|
+
- Replace the compact footer status with an above-editor multi-line widget that shows one row per schedule and coexists with the below-editor Herdr Fleet.
|
|
5
|
+
|
|
1
6
|
## 2026-10-09 - v0.1.0
|
|
2
7
|
|
|
3
8
|
- add session-scoped interval prompts with Agent and user controls
|
|
@@ -25,6 +25,7 @@ schedule({ action: "add", every: "5m", prompt: "Check CI; report completion or f
|
|
|
25
25
|
schedule({ action: "list" })
|
|
26
26
|
schedule({ action: "trigger", id: "schedule-1" })
|
|
27
27
|
schedule({ action: "cancel", id: "schedule-1" })
|
|
28
|
+
schedule({ action: "clear" })
|
|
28
29
|
```
|
|
29
30
|
|
|
30
31
|
## User command
|
|
@@ -34,9 +35,10 @@ schedule({ action: "cancel", id: "schedule-1" })
|
|
|
34
35
|
/schedule list
|
|
35
36
|
/schedule trigger schedule-1
|
|
36
37
|
/schedule cancel schedule-1
|
|
38
|
+
/schedule clear
|
|
37
39
|
```
|
|
38
40
|
|
|
39
|
-
Manual trigger queues one prompt and does not move the regular next time.
|
|
41
|
+
Manual trigger queues one prompt and does not move the regular next time. A multi-line widget above the editor shows one row per active schedule with its ID, interval, next trigger, pending state, and prompt. Herdr Fleet remains below the editor, so the two live displays do not compete for the same area or overwrite each other.
|
|
40
42
|
|
|
41
43
|
## Semantics and limits
|
|
42
44
|
|
|
@@ -44,7 +46,7 @@ Manual trigger queues one prompt and does not move the regular next time. The TU
|
|
|
44
46
|
- Prompts use Pi `followUp`: an active agent turn finishes before the scheduled prompt runs.
|
|
45
47
|
- At most one prompt per schedule remains pending; missed intervals coalesce instead of flooding the queue.
|
|
46
48
|
- Schedules live only in the current session and are cleared on new/resume/fork/reload/quit.
|
|
47
|
-
- Cancel stops
|
|
49
|
+
- Cancel stops one schedule; clear stops all active schedules. Pi cannot selectively withdraw a prompt already in its follow-up queue.
|
|
48
50
|
- Timing is best-effort, not real-time. Recursive `setTimeout` uses absolute target times to avoid cumulative drift.
|
|
49
51
|
- Calendar expressions, time zones, persistence, process monitoring, and script notifications are intentionally out of scope.
|
|
50
52
|
|
|
@@ -8,7 +8,7 @@ npm --prefix packages/scheduler run load-test
|
|
|
8
8
|
npm pack ./packages/scheduler --dry-run
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
-
The deterministic core test uses a fake clock to verify duration parsing, absolute-time cadence, timed triggering, missed-tick coalescing, manual triggering, dropped-follow-up release, and
|
|
11
|
+
The deterministic core test uses a fake clock to verify duration parsing, absolute-time cadence, timed triggering, missed-tick coalescing, manual triggering, dropped-follow-up release, single cancellation, and clear-all timer cleanup. The extension test verifies tool/command parity, manual follow-up delivery, message-specific pending acknowledgement, settled-run fallback cleanup, the above-editor multi-line widget, coexistence with the below-editor Herdr Fleet, duplicate suppression, clear, and shutdown timer cleanup.
|
|
12
12
|
|
|
13
13
|
For a manual TUI check, run Pi with `-e ./packages/scheduler/extensions/scheduler.ts`, create a short schedule, confirm the status line appears, and verify the prompt starts only after the current turn settles.
|
|
14
14
|
|
|
Binary file
|
|
Binary file
|