@maxiaochao/pi-toolkit 0.11.4 → 0.11.5
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 +6 -0
- package/README.md +2 -0
- package/package.json +1 -1
- package/packages/herdr-subagents/ARCHITECTURE.md +2 -1
- package/packages/herdr-subagents/CHANGELOG.md +10 -0
- package/packages/herdr-subagents/README.md +47 -4
- package/packages/herdr-subagents/TESTING.md +8 -4
- package/packages/herdr-subagents/extensions/herdr-subagents.ts +1 -1
- package/packages/herdr-subagents/package.json +3 -3
- package/packages/herdr-subagents/src/batch-runner.ts +2 -1
- package/packages/herdr-subagents/src/config.ts +11 -5
- package/packages/herdr-subagents/src/history.ts +2 -2
- package/packages/herdr-subagents/src/live-agent.ts +2 -0
- package/packages/herdr-subagents/src/personas.ts +5 -0
- package/packages/herdr-subagents/src/records.ts +1 -0
- package/packages/herdr-subagents/src/tool.ts +2 -2
- package/packages/herdr-subagents/tests/cleanup-timing.mjs +61 -0
- package/packages/herdr-subagents/tests/extension-sources-load.mjs +52 -0
- package/packages/herdr-subagents/tests/real-herdr-smoke.mjs +124 -5
- package/packages/herdr-subagents/tests/run-tests.mjs +57 -6
- package/packages/herdr-subagents/tests/wait-for.mjs +12 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
## 2026-10-13 - v0.11.5
|
|
2
|
+
|
|
3
|
+
- bundle herdr-subagents v0.4.14: allow explicit extension sources with native Pi package semantics and reliable cleanup checks
|
|
4
|
+
|
|
1
5
|
## 2026-10-12 - v0.11.4
|
|
2
6
|
|
|
3
7
|
- bundle herdr-subagents v0.4.13: fix stale-idle child startup race and require real Herdr E2E release smoke
|
|
@@ -8,6 +12,8 @@
|
|
|
8
12
|
|
|
9
13
|
## Unreleased
|
|
10
14
|
|
|
15
|
+
- Add opt-in Herdr Subagents persona extension sources, including npm/git Pi packages, with saved restart configuration and documented native package-resource semantics.
|
|
16
|
+
- Harden Herdr Subagents cleanup checks with condition-based waits, lock/focus regressions, and retained failure diagnostics; keep production cleanup behavior unchanged.
|
|
11
17
|
- Fix Herdr Subagents startup failures caused by stale idle observations and require real child-Agent smoke tests before releasing that package.
|
|
12
18
|
|
|
13
19
|
- Replace split child/root publishing with one idempotent root-tag workflow, make final-version local checks authoritative, keep CI publication-only, and remove timing-sensitive Herdr lifecycle assertions.
|
package/README.md
CHANGED
|
@@ -39,6 +39,8 @@ Restart Pi after updating. The `todo` tool and widget will no longer load. `pi u
|
|
|
39
39
|
|
|
40
40
|
Each child README owns its feature description, motivation or upstream alignment, examples, screenshots/demos, configuration, and test documentation.
|
|
41
41
|
|
|
42
|
+
Herdr Subagents personas can opt into local extension files or npm/git Pi packages through `extensions`. This uses native Pi package semantics: package-provided skills, prompt templates, and themes may also load, and `--no-skills` does not exclude explicitly selected package skills. See the [extension-source contract](packages/herdr-subagents/README.md#explicit-extension-sources-and-pi-packages) for precedence, isolation, and examples.
|
|
43
|
+
|
|
42
44
|
Multiple standalone children may be installed together. `pi-todo` may also be installed alongside the complete toolkit because Todo is no longer bundled. Do not install another bundled child alongside the complete toolkit because that exposes the same resource twice.
|
|
43
45
|
|
|
44
46
|
To keep Todo after updating the toolkit:
|
package/package.json
CHANGED
|
@@ -275,7 +275,7 @@ Merging them into one physical file would either put long prompts into JSON or r
|
|
|
275
275
|
|
|
276
276
|
### Sources of confusion to remove
|
|
277
277
|
|
|
278
|
-
1. Persona Markdown and JSON both expose `model`, `thinking`, and `
|
|
278
|
+
1. Persona Markdown and JSON both expose `model`, `thinking`, `skills`, and `extensions`; this is intentional override layering but must be described as one resolution pipeline.
|
|
279
279
|
2. Call-level task model overrides are resolved later in `tool.ts`, after configuration loading.
|
|
280
280
|
3. TypeBox validates shape while `validation.ts` validates action-dependent requirements; these are complementary but currently look like duplicated validation.
|
|
281
281
|
4. Downstream modules receive partially resolved values instead of one canonical run plan.
|
|
@@ -313,6 +313,7 @@ interface ResolvedRunPlan {
|
|
|
313
313
|
thinking?: string;
|
|
314
314
|
tools?: string[];
|
|
315
315
|
skills: string[];
|
|
316
|
+
extensions: string[];
|
|
316
317
|
systemPrompt: string;
|
|
317
318
|
}>;
|
|
318
319
|
provenance: Record<string, string>;
|
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
## 2026-10-13 - v0.4.14
|
|
2
|
+
|
|
3
|
+
- allow explicit extension sources with native Pi package semantics and reliable cleanup checks
|
|
4
|
+
- Add persona `extensions` in Markdown and JSON with project → global → effective Markdown precedence, replacement arrays, and explicit clearing.
|
|
5
|
+
- Pass local files/directories and native npm/git/built-in sources to child Pi while retaining `--no-extensions` and mandatory child extensions.
|
|
6
|
+
- Persist sources for historical, failed/blocked, and queued-task restarts; expose effective sources in persona listing.
|
|
7
|
+
- Document that explicit Pi packages can load skills, prompts, and themes, including package skills despite `--no-skills`, without automatically broadening explicit tool allowlists.
|
|
8
|
+
- Add deterministic source/precedence/restart tests, a real Pi file/package loading check, and package-source coverage in the real concurrent Herdr smoke.
|
|
9
|
+
- Replace deferred-cleanup test sleeps with bounded condition waits; add a controlled lock-contention regression. Make real Herdr smoke respect deferred cleanup, optionally test focus/restore, and preserve failure traces and live tab/pane snapshots instead of deleting the evidence.
|
|
10
|
+
|
|
1
11
|
## 2026-10-12 - v0.4.13
|
|
2
12
|
|
|
3
13
|
- fix stale-idle child startup race and require real Herdr E2E release smoke
|
|
@@ -160,7 +160,7 @@ These are all supported top-level fields in `herdr-subagents.json`:
|
|
|
160
160
|
| `defaultThinking` | `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, or `max` | Unset | Thinking level below persona-specific settings. Provider/model support can vary. If unset, parent thinking is inherited only when the parent model is also inherited. |
|
|
161
161
|
| `personas` | Object keyed by an existing persona name | `{}` | Per-persona JSON overrides described below. This does not create a persona; a matching Markdown definition must exist. |
|
|
162
162
|
|
|
163
|
-
Known fields with an invalid JSON type, an empty required string, or a non-positive integer are ignored with a diagnostic shown by `/herdr-subagents settings`. Model IDs, thinking-level compatibility, and skill availability are ultimately
|
|
163
|
+
Known fields with an invalid JSON type, an empty required string, or a non-positive integer are ignored with a diagnostic shown by `/herdr-subagents settings`. Model IDs, thinking-level compatibility, and skill/extension source availability are ultimately handled by Pi when the child starts. When `defaultConcurrency` exceeds `maxConcurrency`, the effective default is reduced to the maximum.
|
|
164
164
|
|
|
165
165
|
Each `personas.<name>` object supports exactly these overrides:
|
|
166
166
|
|
|
@@ -168,7 +168,8 @@ Each `personas.<name>` object supports exactly these overrides:
|
|
|
168
168
|
| --- | --- | --- |
|
|
169
169
|
| `model` | Non-empty Pi model string | Overrides the model from the effective persona Markdown and `defaultModel`. |
|
|
170
170
|
| `thinking` | `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, or `max` | Overrides persona Markdown and `defaultThinking`. The value is passed to Pi's `--thinking` option. |
|
|
171
|
-
| `skills` | Array of non-empty strings | Replaces the entire lower-precedence skill list. Use `[]` to clear
|
|
171
|
+
| `skills` | Array of non-empty strings | Replaces the entire lower-precedence explicit skill list. Use `[]` to clear that list. Each item is passed to child Pi as `--skill`. Package-provided skills are separate; see below. |
|
|
172
|
+
| `extensions` | Array of non-empty Pi source strings | Replaces the entire lower-precedence extension-source list. Use `[]` to clear it. Each item is passed as `--extension` after the mandatory child extensions. Accepts local files/directories, `npm:` and `git:` Pi packages, and `builtin:` sources. |
|
|
172
173
|
|
|
173
174
|
JSON persona overrides cannot change `name`, `description`, `access`, `tools`, or the system prompt; define those in persona Markdown.
|
|
174
175
|
|
|
@@ -281,7 +282,49 @@ The child thinking level is selected in this order:
|
|
|
281
282
|
4. effective `defaultThinking` JSON setting (project overrides global)
|
|
282
283
|
5. parent Pi thinking level when the child also inherits the parent model
|
|
283
284
|
|
|
284
|
-
Thinking has no call-level override. `skills`
|
|
285
|
+
Thinking has no call-level override. `skills` and `extensions` use the first three persona-level steps and have no global default. A project array replaces, rather than appends to, the global or effective Markdown list; `[]` explicitly clears it. There is no task-level extension override.
|
|
286
|
+
|
|
287
|
+
### Explicit extension sources and Pi packages
|
|
288
|
+
|
|
289
|
+
For a local routing hook, add this frontmatter to a persona Markdown file (the hook must already exist):
|
|
290
|
+
|
|
291
|
+
```md
|
|
292
|
+
---
|
|
293
|
+
name: routed-explorer
|
|
294
|
+
description: Explorer with a provider routing hook
|
|
295
|
+
access: read
|
|
296
|
+
tools: read, bash, grep, find, ls, codemode
|
|
297
|
+
extensions: ~/.pi/agent/extensions/gateway-routing.ts
|
|
298
|
+
---
|
|
299
|
+
Inspect the requested code and report your findings.
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
Frontmatter accepts a single source or a comma-separated inline list, optionally enclosed in brackets. Use `extensions: []` to clear the list. Like the existing persona format, it does not support multiline YAML lists.
|
|
303
|
+
|
|
304
|
+
JSON accepts an array only. For example, override the built-in explorer with a local hook and an npm Pi package:
|
|
305
|
+
|
|
306
|
+
```json
|
|
307
|
+
{
|
|
308
|
+
"personas": {
|
|
309
|
+
"explorer": {
|
|
310
|
+
"extensions": [
|
|
311
|
+
"~/.pi/agent/extensions/gateway-routing.ts",
|
|
312
|
+
"npm:@example/pi-tools@1.0.0"
|
|
313
|
+
]
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
`@example/pi-tools` is illustrative; replace it with a trusted Pi package. An ordinary npm library is not automatically a Pi extension package. Pin npm versions or git refs when reproducible restarts matter. Relative local paths resolve from the child working directory (the project root), **not** from the JSON or Markdown file. `~` resolves to the user's home directory. Source strings are passed to Pi without rewriting `npm:`, `git:`, or `builtin:` identifiers.
|
|
320
|
+
|
|
321
|
+
**This field uses Pi's native package-loading semantics. It is not an extensions-only resource filter.** A file loads that extension. A Pi package can also load its declared **skills, prompt templates, and themes**. In particular, explicitly selected package skills still load despite the child's `--no-skills`; persona `skills: []` clears only the explicit skill list, not skills carried by an extension package. To select one extension without the package's other resources, use its local extension-file path or a dedicated package that exposes only the intended resources. The string list does not support Pi's settings object-form resource filters.
|
|
322
|
+
|
|
323
|
+
Children still start with `--no-extensions`, then explicitly load `builtin:codemode`, the bundled `subagent-report`, and the configured sources in order. Unconfigured personas retain their existing behavior. The list does not inherit the parent's extensions, installed-package settings, or package resource filters. Pi manages source resolution, caching, dependency installation, and startup diagnostics; an npm/git source may require network access. Herdr Subagents does not implement a separate installer or guarantee that Pi's extension warnings terminate a task. Inspect the child's startup diagnostics when a required routing hook does not run.
|
|
324
|
+
|
|
325
|
+
Effective sources are saved in each task record and shown by persona listing. Historical resume, failed/blocked-task restarts, and queued-task recovery reuse the saved list instead of re-reading current persona overrides. A live child is not reloaded when configuration changes. Saving a source does not freeze its code: local files and unpinned package versions can change.
|
|
326
|
+
|
|
327
|
+
Load only trusted extension code. `access: read` controls scheduling; it is not a sandbox for extension hooks, which can perform side effects outside model tools. `PI_HERDR_SUBAGENTS_CHILD` prevents **this package** from registering recursively, not arbitrary third-party extensions. Loading a package does not broaden an explicit `tools` allowlist. Persona `tools` still accepts only the existing built-in names; adding extension-defined tools to that schema is a separate feature.
|
|
285
328
|
|
|
286
329
|
Override the model for one task:
|
|
287
330
|
|
|
@@ -313,7 +356,7 @@ Queued background submissions are persisted before dispatch. Closing the parent
|
|
|
313
356
|
|
|
314
357
|
Tab, pane, and child startup commands are not automatically retried: an error may arrive after Herdr already created the resource. Recovery is best effort, not complete: Herdr 0.9.2 has no idempotency key for tab creation or pane splitting, so a `SIGKILL` after the external resource is created but before its ID is durably persisted can leave an orphan that cannot be identified for cleanup. A failure after prompt submission is preserved and never automatically rerun. Historical follow-ups use a recoverable task-scoped `.resume.lock`, so different tasks can continue concurrently without allowing two writers for one saved session. Herdr exposes no retrying state, and Pi exposes no reliable public auto-retry hook, so `retrying` is supported by the runtime schema and Fleet but is not fabricated from ambiguous lifecycle events. `stalledWarningSeconds` emits a warning without killing long-running work. Run/task/report snapshots use atomic replacement so interruption cannot expose half-written JSON.
|
|
315
358
|
|
|
316
|
-
The child does not inherit parent extensions
|
|
359
|
+
The child does not inherit parent extensions or parent conversation. Skills load from explicit persona paths and from explicitly selected Pi packages; normal project context files still apply. It uses the same Pi installation, provider configuration, model catalog, and credentials as the parent.
|
|
317
360
|
|
|
318
361
|
## Development and tests
|
|
319
362
|
|
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
## Flow
|
|
4
4
|
|
|
5
|
-
1. `npm --prefix packages/herdr-subagents test` runs deterministic scheduler, recovery, and persona tests.
|
|
6
|
-
2. `
|
|
7
|
-
3. From a Pi session inside Herdr, run `npm --prefix packages/herdr-subagents run test:real-herdr`. This is a mandatory child-package and root-bundle release gate. It starts one real child Pi and then two concurrent read-only child Pi Agents
|
|
5
|
+
1. `npm --prefix packages/herdr-subagents test` runs deterministic scheduler, recovery, and persona tests, then a controlled run-state-lock regression for deferred cleanup. The regression proves that the old 10 ms assertion can observe unfinished cleanup while both successful cleanup and error persistence eventually settle after lock release; it also checks that the condition waiter fails on timeout.
|
|
6
|
+
2. `PI_TEST_BINARY="$(command -v pi)" npm --prefix packages/herdr-subagents run load-test` verifies extension contracts and loading, then starts real Pi with generated child arguments against temporary file/package fixtures. The package fixture must expose its skill and prompt despite discovery flags, while the explicit tool allowlist remains unchanged. This check makes no provider calls or network installs. `PI_TEST_BINARY` pins the tested CLI so npm cannot substitute an older workspace-local Pi binary; use a Pi version supporting native `builtin:codemode` and package sources.
|
|
7
|
+
3. From a Pi session inside Herdr, run `npm --prefix packages/herdr-subagents run test:real-herdr`. This is a mandatory child-package and root-bundle release gate. It starts one unconfigured real child Pi and then two concurrent read-only child Pi Agents with an explicit local Pi package. It requires assistant events, matching attempt-scoped reports, saved extension sources, a package-extension startup marker, package skill availability despite `--no-skills`, and final tab cleanup. `HERDR_SUBAGENTS_E2E_MODEL=provider/model` optionally pins the smoke model. The check waits up to five seconds for pane removal and the durable cleanup commit; a recorded `cleanupError` fails immediately instead of being retried away. A failed assertion or runtime check preserves the temporary durable records, `trace.json`, and `failure.json` with tab/pane snapshots. Teardown errors are retained separately and fail the gate; already-missing tabs are successful cleanup.
|
|
8
|
+
|
|
9
|
+
For an intentional focus regression, run `HERDR_SUBAGENTS_E2E_FOCUS_CLEANUP=1 HERDR_SUBAGENTS_E2E_MODEL=provider/model npm --prefix packages/herdr-subagents run test:real-herdr`. It briefly focuses only the concurrent test's tab at cleanup, requires `cleanupPendingTabIds`, restores the globally focused workspace and its active tab, asserts that focus is still restored after cleanup, then verifies automatic cleanup without manually closing the test tab. Run this only when brief visible focus changes are acceptable; it requires an existing focused tab to restore.
|
|
8
10
|
4. From a dedicated visible X11 Herdr client/window with the GStreamer X11 and VP8 plugins installed, run `HERDR_MEDIA_WINDOW_ID=<window-id> node scripts/capture-package-media.mjs`. Capture intentionally cycles through the live child panes, so avoid using that client/window for other work while recording; the script restores the previously focused pane afterward when it still exists. The Herdr capture must show a parent Pi conversation, a three-task run tab, each of the three real interactive Agent panes receiving focus in turn, the active parent Fleet footer with yellow running indicators and real metrics, and the final compact parent response. The published screenshot must be the parent Fleet view, while the full WebM preserves the parent → three focused children → Fleet → parent sequence.
|
|
9
11
|
5. `npm pack ./packages/herdr-subagents --dry-run` verifies package contents.
|
|
10
12
|
6. Verify the README renders the GitHub attachment video directly and live Herdr UI media `docs/screenshot.png` and `docs/demo.webm` are present in the tarball; `docs/demo.gif` must be absent. Inspect the WebM metadata and play it through once to confirm the parent → three focused children → Fleet → parent sequence.
|
|
@@ -12,6 +14,7 @@
|
|
|
12
14
|
## Cases
|
|
13
15
|
|
|
14
16
|
- Resolve package, global, and project personas and validate frontmatter/configuration, including the neutral read-only `analyst` persona.
|
|
17
|
+
- Verify extension-source Markdown parsing, JSON type diagnostics, precedence, trimming, replacement/empty lists, unchanged unconfigured startup, local/npm source argument preservation, persona listing, durable history, historical resume, blocked-task restart, and queued-task recovery.
|
|
15
18
|
- Run foreground tasks and FIFO bounded-concurrency batches through an injectable Herdr boundary.
|
|
16
19
|
- 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.
|
|
17
20
|
- 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.
|
|
@@ -37,6 +40,7 @@
|
|
|
37
40
|
- Simulate a false screen-settled return while the authoritative Pi lifecycle remains working, then write the report later and verify the task completes without a transient failed state.
|
|
38
41
|
- Verify reports are bound to unique attempt IDs, blocked results are not tool errors, duplicate blocked responses are serialized, mixed failed/cancelled aggregates remain partial, and cleanup failures never overwrite business outcomes.
|
|
39
42
|
- Verify settling a batch merges run state written concurrently under the run lock instead of overwriting `run.json` from a stale in-memory copy.
|
|
43
|
+
- Wait for deferred cleanup commits/notifications rather than fixed sleeps. Hold the real run-state lock across the old assertion point to reproduce both successful-cleanup and error-persistence races, then require the expected durable state after release. Real smoke failures retain evidence before teardown.
|
|
40
44
|
- Verify an explicitly named failed task continues from its saved Pi session: it reuses its own live pane while its batch still runs, restarts in a new pane when that pane is gone, refuses to continue without a saved session, is never selected by an unqualified response, and does not start queued siblings that the running batch still owns.
|
|
41
45
|
- Verify a settling batch adopts the task records on disk, so a continued task keeps its run `running` and its Herdr tab open instead of being overwritten by the batch's own stale view.
|
|
42
46
|
- Verify a continuation claims its task before touching any pane, so a sibling continuation that finishes first leaves the shared tab open, and only the last one to settle closes it.
|
|
@@ -46,4 +50,4 @@
|
|
|
46
50
|
|
|
47
51
|
## Latest baseline
|
|
48
52
|
|
|
49
|
-
All
|
|
53
|
+
All 266 deterministic scheduler, recovery, process-boundary, schema-validation, navigation, metrics, responsive-formatting, lifecycle, attempt-isolation, and persona cases, the controlled cleanup-timing regression, and the extension-contract lifecycle simulations must pass. The startup regression test reproduces the real ordering where `before_agent_start` records the attempt as working while Herdr can still report stale idle. Every release also runs the real single-Agent and concurrent read-only Herdr smoke; a skipped smoke is not a passing release check.
|
|
@@ -88,7 +88,7 @@ async function handleHerdrCommand(args: string, ctx: ExtensionCommandContext, fl
|
|
|
88
88
|
}
|
|
89
89
|
const configuration = loadSubagentConfiguration(ctx.cwd, packageAgentsDir, ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined);
|
|
90
90
|
if (section === "agents") {
|
|
91
|
-
ctx.ui.notify(configuration.personas.map((persona) => `${persona.name}: ${persona.source}, ${persona.access}, model=${persona.model ?? "unresolved"} (${persona.modelSource}), thinking=${persona.thinking ?? "default"}, tools=${persona.tools?.join(",") ?? "default"}, skills=${persona.skills.join(",") || "none"}`).join("\n"), "info");
|
|
91
|
+
ctx.ui.notify(configuration.personas.map((persona) => `${persona.name}: ${persona.source}, ${persona.access}, model=${persona.model ?? "unresolved"} (${persona.modelSource}), thinking=${persona.thinking ?? "default"}, tools=${persona.tools?.join(",") ?? "default"}, skills=${persona.skills.join(",") || "none"}, extensions=${persona.extensions.join(",") || "none"} (${persona.extensionsSource})`).join("\n"), "info");
|
|
92
92
|
return;
|
|
93
93
|
}
|
|
94
94
|
if (section === "settings") {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@maxiaochao/pi-herdr-subagents",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.14",
|
|
4
4
|
"description": "Run inspectable interactive Pi subagent batches through Herdr.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
@@ -37,9 +37,9 @@
|
|
|
37
37
|
"typebox": "*"
|
|
38
38
|
},
|
|
39
39
|
"scripts": {
|
|
40
|
-
"test": "node --experimental-strip-types tests/run-tests.mjs",
|
|
40
|
+
"test": "node --experimental-strip-types tests/run-tests.mjs && node --experimental-strip-types tests/cleanup-timing.mjs",
|
|
41
41
|
"test:real-herdr": "node --experimental-strip-types tests/real-herdr-smoke.mjs",
|
|
42
|
-
"load-test": "env -u PI_HERDR_SUBAGENTS_CHILD pi -ne -ns --no-session -e ./tests/extension-contract.ts && env -u PI_HERDR_SUBAGENTS_CHILD pi -ne -ns --no-session -e ./extensions/herdr-subagents.ts"
|
|
42
|
+
"load-test": "env -u PI_HERDR_SUBAGENTS_CHILD \"${PI_TEST_BINARY:-pi}\" -ne -ns --no-session -e ./tests/extension-contract.ts && env -u PI_HERDR_SUBAGENTS_CHILD \"${PI_TEST_BINARY:-pi}\" -ne -ns --no-session -e ./extensions/herdr-subagents.ts && node --experimental-strip-types tests/extension-sources-load.mjs"
|
|
43
43
|
},
|
|
44
44
|
"publishConfig": {
|
|
45
45
|
"access": "public"
|
|
@@ -27,6 +27,7 @@ export interface BatchTask {
|
|
|
27
27
|
thinking?: string;
|
|
28
28
|
tools?: string[];
|
|
29
29
|
skills: string[];
|
|
30
|
+
extensions?: string[];
|
|
30
31
|
systemPrompt: string;
|
|
31
32
|
}
|
|
32
33
|
|
|
@@ -103,7 +104,7 @@ export async function runHerdrSubagentsBatch(tasks: BatchTask[], options: BatchO
|
|
|
103
104
|
const taskRecord: TaskRecord = {
|
|
104
105
|
id: taskId, runId, order: index + 1, name: task.name, status: "queued", prompt: task.prompt,
|
|
105
106
|
agent: task.agent, access: task.access, model: task.model, thinking: task.thinking,
|
|
106
|
-
tools: task.tools, skills: task.skills, systemPrompt: reportProtocolPrompt(task.systemPrompt), sessionFile,
|
|
107
|
+
tools: task.tools, skills: task.skills, extensions: task.extensions, systemPrompt: reportProtocolPrompt(task.systemPrompt), sessionFile,
|
|
107
108
|
};
|
|
108
109
|
await mkdir(taskDirectory, { recursive: true, mode: 0o700 });
|
|
109
110
|
await writeFile(systemPromptFile, String(taskRecord.systemPrompt), { encoding: "utf8", mode: 0o600 });
|
|
@@ -4,7 +4,7 @@ import { dirname, join } from "node:path";
|
|
|
4
4
|
import { discoverPackageAgents, type AgentDefinition } from "./personas.ts";
|
|
5
5
|
|
|
6
6
|
export type PersonaAccess = "read" | "write";
|
|
7
|
-
export interface PersonaConfig { model?: string; thinking?: string; skills?: string[]; }
|
|
7
|
+
export interface PersonaConfig { model?: string; thinking?: string; skills?: string[]; extensions?: string[]; }
|
|
8
8
|
export interface SubagentConfig {
|
|
9
9
|
defaultConcurrency: number;
|
|
10
10
|
maxConcurrency: number;
|
|
@@ -22,6 +22,8 @@ export interface EffectivePersona extends AgentDefinition {
|
|
|
22
22
|
thinkingSource: string;
|
|
23
23
|
skills: string[];
|
|
24
24
|
skillsSource: string;
|
|
25
|
+
extensions: string[];
|
|
26
|
+
extensionsSource: string;
|
|
25
27
|
}
|
|
26
28
|
export interface SubagentConfiguration {
|
|
27
29
|
settings: SubagentConfig;
|
|
@@ -83,9 +85,11 @@ function mergeConfig(base: SubagentConfig, input: ConfigFile, source: string, so
|
|
|
83
85
|
if (typeof value === "string" && value.trim()) valid[field] = value.trim();
|
|
84
86
|
else diagnostics.push(`${source} persona '${name}' ${field} must be a non-empty string`);
|
|
85
87
|
}
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
88
|
+
for (const field of ["skills", "extensions"] as const) {
|
|
89
|
+
const value = raw[field];
|
|
90
|
+
if (value === undefined) continue;
|
|
91
|
+
if (Array.isArray(value) && value.every((item) => typeof item === "string" && item.trim())) valid[field] = value.map((item) => item.trim());
|
|
92
|
+
else diagnostics.push(`${source} persona '${name}' ${field} must be an array of non-empty strings`);
|
|
89
93
|
}
|
|
90
94
|
input.personas[name] = valid;
|
|
91
95
|
result.personas[name] = { ...(result.personas[name] ?? {}), ...valid };
|
|
@@ -123,7 +127,9 @@ export function loadSubagentConfiguration(cwd: string, packageAgentsDirectory: s
|
|
|
123
127
|
const thinkingSource = projectPersona.thinking ? "project persona" : globalPersona.thinking ? "global persona" : definition.thinking ? `${source} persona` : settings.defaultThinking ? settingSources.defaultThinking : "default";
|
|
124
128
|
const skills = projectPersona.skills ?? globalPersona.skills ?? definition.skills ?? [];
|
|
125
129
|
const skillsSource = projectPersona.skills ? "project persona" : globalPersona.skills ? "global persona" : definition.skills ? `${source} persona` : "none";
|
|
126
|
-
|
|
130
|
+
const extensions = projectPersona.extensions ?? globalPersona.extensions ?? definition.extensions ?? [];
|
|
131
|
+
const extensionsSource = projectPersona.extensions ? "project persona" : globalPersona.extensions ? "global persona" : definition.extensions ? `${source} persona` : "none";
|
|
132
|
+
return { ...definition, source, access: definition.access ?? "write", model, modelSource, thinking, thinkingSource, skills, skillsSource, extensions, extensionsSource };
|
|
127
133
|
}).sort((left, right) => left.name.localeCompare(right.name));
|
|
128
134
|
return { settings, settingSources, personas, diagnostics };
|
|
129
135
|
}
|
|
@@ -7,7 +7,7 @@ import { isActiveStatus, readReport, readRunRecord, readTaskRecord, type Persist
|
|
|
7
7
|
import { findRunDirectory } from "./run-locator.ts";
|
|
8
8
|
import { resumeHistoricalTaskConcurrent } from "./historical-resume.ts";
|
|
9
9
|
|
|
10
|
-
export interface HistoricalTask { order: number; id: string; name: string; agent: string; status: TaskStatus; summary?: string; question?: string; documents: PersistedDocument[]; recordDirectory: string; sessionFile: string; model?: string; thinking?: string; tools?: string[]; skills: string[]; access?: string; }
|
|
10
|
+
export interface HistoricalTask { order: number; id: string; name: string; agent: string; status: TaskStatus; summary?: string; question?: string; documents: PersistedDocument[]; recordDirectory: string; sessionFile: string; model?: string; thinking?: string; tools?: string[]; skills: string[]; extensions?: string[]; access?: string; }
|
|
11
11
|
export interface HistoricalRun { id: string; label: string; status: RunStatus; startedAt: string; completedAt?: string; effectiveConcurrency?: number; cleanupPendingTabIds?: string[]; tasks: HistoricalTask[]; recordDirectory: string; }
|
|
12
12
|
|
|
13
13
|
export async function ensureRuntimeIgnored(cwd: string): Promise<void> {
|
|
@@ -66,7 +66,7 @@ export async function listSubagentHistory(cwd: string): Promise<HistoricalRun[]>
|
|
|
66
66
|
historyStatus = "failed";
|
|
67
67
|
report = { summary: `Missing structured report: ${reportFile}`, documents: [] };
|
|
68
68
|
}
|
|
69
|
-
tasks.push({ order: task.order ?? tasks.length + 1, id: task.id, name: task.name ?? task.agent ?? "worker", agent: task.agent ?? "worker", status: historyStatus, summary: report.summary, question: report.question, documents: report.documents ?? [], recordDirectory: taskDirectory, sessionFile: task.sessionFile, model: task.model, thinking: task.thinking, tools: task.tools, skills: task.skills ?? [], access: task.access });
|
|
69
|
+
tasks.push({ order: task.order ?? tasks.length + 1, id: task.id, name: task.name ?? task.agent ?? "worker", agent: task.agent ?? "worker", status: historyStatus, summary: report.summary, question: report.question, documents: report.documents ?? [], recordDirectory: taskDirectory, sessionFile: task.sessionFile, model: task.model, thinking: task.thinking, tools: task.tools, skills: task.skills ?? [], extensions: task.extensions ?? [], access: task.access });
|
|
70
70
|
}
|
|
71
71
|
history.push({ id: run.id, label: run.label, status: projectedStatus, startedAt: run.startedAt, completedAt: run.completedAt, effectiveConcurrency: run.effectiveConcurrency, cleanupPendingTabIds: run.cleanupPendingTabIds, tasks, recordDirectory: directory });
|
|
72
72
|
} catch (error) {
|
|
@@ -16,6 +16,7 @@ export interface LiveTaskRecord {
|
|
|
16
16
|
thinking?: string;
|
|
17
17
|
tools?: string[];
|
|
18
18
|
skills?: string[];
|
|
19
|
+
extensions?: string[];
|
|
19
20
|
currentAttemptId?: string;
|
|
20
21
|
}
|
|
21
22
|
|
|
@@ -41,6 +42,7 @@ export function childPiArgs(task: LiveTaskRecord, systemPromptFile: string): str
|
|
|
41
42
|
"--no-skills",
|
|
42
43
|
"--append-system-prompt", systemPromptFile,
|
|
43
44
|
];
|
|
45
|
+
for (const extension of task.extensions ?? []) args.push("--extension", extension);
|
|
44
46
|
if (task.model) args.push("--model", task.model);
|
|
45
47
|
if (task.thinking) args.push("--thinking", task.thinking);
|
|
46
48
|
if (task.tools?.length) args.push("--tools", [...new Set([...task.tools, "codemode", "subagent_report"])].join(","));
|
|
@@ -21,6 +21,7 @@ export interface AgentDefinition {
|
|
|
21
21
|
access?: "read" | "write";
|
|
22
22
|
thinking?: string;
|
|
23
23
|
skills?: string[];
|
|
24
|
+
extensions?: string[];
|
|
24
25
|
systemPrompt: string;
|
|
25
26
|
filePath: string;
|
|
26
27
|
}
|
|
@@ -79,6 +80,9 @@ export function parseAgentMarkdown(content: string, filePath = "<memory>"): Agen
|
|
|
79
80
|
const access = rawAccess as "read" | "write" | undefined;
|
|
80
81
|
const thinking = fields.get("thinking")?.trim() || undefined;
|
|
81
82
|
const skills = fields.has("skills") ? parseList(fields.get("skills") ?? "") : undefined;
|
|
83
|
+
const rawExtensions = fields.get("extensions");
|
|
84
|
+
const extensions = rawExtensions?.trim() === "[]" ? [] : rawExtensions === undefined ? undefined : parseList(rawExtensions);
|
|
85
|
+
if (rawExtensions !== undefined && !extensions) throw new Error("frontmatter extensions must contain non-empty sources or []");
|
|
82
86
|
return {
|
|
83
87
|
name,
|
|
84
88
|
description,
|
|
@@ -87,6 +91,7 @@ export function parseAgentMarkdown(content: string, filePath = "<memory>"): Agen
|
|
|
87
91
|
...(access ? { access } : {}),
|
|
88
92
|
...(thinking ? { thinking } : {}),
|
|
89
93
|
...(skills ? { skills } : {}),
|
|
94
|
+
...(extensions ? { extensions } : {}),
|
|
90
95
|
systemPrompt: lines.slice(end + 1).join("\n").trim(),
|
|
91
96
|
filePath,
|
|
92
97
|
};
|
|
@@ -73,7 +73,7 @@ export function listText(discovery: SubagentConfiguration): string {
|
|
|
73
73
|
const model = agent.model ?? "unresolved";
|
|
74
74
|
const tools = agent.tools?.join(", ") ?? "Pi default built-in tools";
|
|
75
75
|
const skills = agent.skills.join(", ") || "none";
|
|
76
|
-
lines.push(`- ${agent.name}: ${agent.description} (source: ${agent.source}; access: ${agent.access}; model: ${model} [${agent.modelSource}]; thinking: ${agent.thinking ?? "default"}; tools: ${tools}; skills: ${skills})`);
|
|
76
|
+
lines.push(`- ${agent.name}: ${agent.description} (source: ${agent.source}; access: ${agent.access}; model: ${model} [${agent.modelSource}]; thinking: ${agent.thinking ?? "default"}; tools: ${tools}; skills: ${skills}; extensions: ${agent.extensions.join(", ") || "none"} [${agent.extensionsSource}])`);
|
|
77
77
|
}
|
|
78
78
|
if (discovery.diagnostics.length > 0) {
|
|
79
79
|
lines.push("", "Persona diagnostics:", ...discovery.diagnostics.map((diagnostic) => `- ${diagnostic}`));
|
|
@@ -184,7 +184,7 @@ async function executeBatch({ params, signal, onUpdate, ctx, dependencies, disco
|
|
|
184
184
|
const personaName = task.agent?.trim() || "worker";
|
|
185
185
|
const persona = discovery.personas.find((candidate) => candidate.name === personaName);
|
|
186
186
|
if (!persona) return errorResponse(`Unknown persona '${personaName}'.\n\n${listText(discovery)}`);
|
|
187
|
-
resolved.push({ name: task.name, prompt: task.prompt, agent: persona.name, access: persona.access, model: task.model?.trim() || persona.model, thinking: persona.thinking ?? (persona.modelSource === "parent session" && !task.model ? ctx.thinkingLevel : undefined), tools: persona.tools, skills: persona.skills, systemPrompt: persona.systemPrompt });
|
|
187
|
+
resolved.push({ name: task.name, prompt: task.prompt, agent: persona.name, access: persona.access, model: task.model?.trim() || persona.model, thinking: persona.thinking ?? (persona.modelSource === "parent session" && !task.model ? ctx.thinkingLevel : undefined), tools: persona.tools, skills: persona.skills, extensions: persona.extensions, systemPrompt: persona.systemPrompt });
|
|
188
188
|
}
|
|
189
189
|
try {
|
|
190
190
|
const result = await runHerdrSubagentsBatch(resolved, {
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { cleanupRunTab } from "../src/tab-cleanup.ts";
|
|
6
|
+
import { acquireDirectoryLock } from "../src/run-state.ts";
|
|
7
|
+
import { writeJsonAtomic } from "../src/state.ts";
|
|
8
|
+
import { waitFor } from "./wait-for.mjs";
|
|
9
|
+
|
|
10
|
+
const root = mkdtempSync(join(tmpdir(), "pi-herdr-cleanup-timing-"));
|
|
11
|
+
try {
|
|
12
|
+
await assert.rejects(waitFor(() => false, "controlled missing completion", 20), /Timed out.*controlled missing completion/);
|
|
13
|
+
for (const failClose of [false, true]) {
|
|
14
|
+
const runFile = join(root, "run.json");
|
|
15
|
+
const run = { id: "cleanup-timing", label: "cleanup-timing", status: "completed" };
|
|
16
|
+
await writeJsonAtomic(runFile, run);
|
|
17
|
+
let leaveFocus;
|
|
18
|
+
const focus = new Promise((resolve) => { leaveFocus = resolve; });
|
|
19
|
+
let closeAttempted = false;
|
|
20
|
+
const herdr = {
|
|
21
|
+
async isTabFocused() { return true; },
|
|
22
|
+
async waitForTabUnfocused() { await focus; },
|
|
23
|
+
async closeTab() {
|
|
24
|
+
closeAttempted = true;
|
|
25
|
+
if (failClose) throw new Error("controlled close failure");
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
const cleanup = await cleanupRunTab({ herdr, tabId: "test-tab", runFile, runRecord: run });
|
|
29
|
+
assert.equal(cleanup.deferred, true);
|
|
30
|
+
const lock = await acquireDirectoryLock(join(root, ".state.lock"), { wait: true });
|
|
31
|
+
try {
|
|
32
|
+
leaveFocus();
|
|
33
|
+
await waitFor(() => closeAttempted, "controlled close attempt");
|
|
34
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
35
|
+
const premature = JSON.parse(readFileSync(runFile, "utf8"));
|
|
36
|
+
assert.deepEqual(premature.cleanupPendingTabIds, ["test-tab"]);
|
|
37
|
+
assert.equal(premature.cleanupError, undefined);
|
|
38
|
+
console.log(`REPRO ${failClose ? "error persistence" : "successful cleanup"}: at the old 10 ms assertion point, the legitimate run-state lock keeps cleanup pending`);
|
|
39
|
+
} finally {
|
|
40
|
+
await lock.release();
|
|
41
|
+
}
|
|
42
|
+
const settled = await waitFor(() => {
|
|
43
|
+
const record = JSON.parse(readFileSync(runFile, "utf8"));
|
|
44
|
+
return (failClose ? record.cleanupError : !record.cleanupPendingTabIds) ? record : false;
|
|
45
|
+
}, "cleanup state commit");
|
|
46
|
+
assert.equal(settled.status, "completed");
|
|
47
|
+
if (failClose) {
|
|
48
|
+
assert.equal(settled.cleanupError, "controlled close failure");
|
|
49
|
+
assert.deepEqual(settled.cleanupPendingTabIds, ["test-tab"]);
|
|
50
|
+
} else {
|
|
51
|
+
assert.equal(settled.cleanupPendingTabIds, undefined);
|
|
52
|
+
assert.equal(settled.cleanupError, undefined);
|
|
53
|
+
}
|
|
54
|
+
// Wait for the detached chain's lock release before reusing or deleting its record.
|
|
55
|
+
const joined = await acquireDirectoryLock(join(root, ".state.lock"), { wait: true });
|
|
56
|
+
await joined.release();
|
|
57
|
+
console.log(`PASS ${failClose ? "error persistence" : "successful cleanup"} settles after the lock releases`);
|
|
58
|
+
}
|
|
59
|
+
} finally {
|
|
60
|
+
rmSync(root, { recursive: true, force: true });
|
|
61
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { childPiArgs } from "../src/live-agent.ts";
|
|
7
|
+
|
|
8
|
+
// Exercise native Pi package semantics without provider calls or network installs.
|
|
9
|
+
const root = mkdtempSync(join(tmpdir(), "pi-herdr-extension-sources-"));
|
|
10
|
+
try {
|
|
11
|
+
const packageDirectory = join(root, "local package");
|
|
12
|
+
mkdirSync(join(packageDirectory, "skills", "package-skill"), { recursive: true });
|
|
13
|
+
mkdirSync(join(packageDirectory, "prompts"), { recursive: true });
|
|
14
|
+
writeFileSync(join(packageDirectory, "package.json"), JSON.stringify({
|
|
15
|
+
name: "extension-source-fixture",
|
|
16
|
+
pi: { extensions: ["extension.ts"], skills: ["skills"], prompts: ["prompts"] },
|
|
17
|
+
}));
|
|
18
|
+
writeFileSync(join(packageDirectory, "extension.ts"), `export default function (pi) {
|
|
19
|
+
pi.registerCommand("package-extension", { description: "Package extension loaded", handler() {} });
|
|
20
|
+
}`);
|
|
21
|
+
writeFileSync(join(packageDirectory, "skills", "package-skill", "SKILL.md"), "---\nname: package-skill\ndescription: Package skill loading probe\n---\n# Probe\n");
|
|
22
|
+
writeFileSync(join(packageDirectory, "prompts", "package-prompt.md"), "Package prompt loading probe.");
|
|
23
|
+
const fileExtension = join(root, "file extension.ts");
|
|
24
|
+
writeFileSync(fileExtension, `export default function (pi) {
|
|
25
|
+
pi.registerCommand("file-extension", { description: "File extension loaded", handler() {} });
|
|
26
|
+
}`);
|
|
27
|
+
const probe = join(root, "probe.ts");
|
|
28
|
+
const resultPath = join(root, "result.json");
|
|
29
|
+
writeFileSync(probe, `import { writeFileSync } from "node:fs";
|
|
30
|
+
export default function (pi) {
|
|
31
|
+
pi.on("session_start", (_event, ctx) => {
|
|
32
|
+
writeFileSync(${JSON.stringify(resultPath)}, JSON.stringify({
|
|
33
|
+
commands: pi.getCommands().map((command) => command.name),
|
|
34
|
+
tools: pi.getActiveTools(),
|
|
35
|
+
}));
|
|
36
|
+
ctx.shutdown();
|
|
37
|
+
});
|
|
38
|
+
}`);
|
|
39
|
+
const args = childPiArgs({ id: "probe", sessionFile: join(root, "session.jsonl"), tools: ["read"], extensions: [fileExtension, "./local package", probe] }, join(root, "system.md"));
|
|
40
|
+
writeFileSync(join(root, "system.md"), "Extension source loading probe.");
|
|
41
|
+
const env = { ...process.env, PI_CODING_AGENT_DIR: join(root, "agent"), PI_HERDR_SUBAGENTS_CHILD: "1" };
|
|
42
|
+
for (const key of Object.keys(env)) if (key.startsWith("HERDR_") || key === "PI_HERDR_SUBAGENTS_TASK_DIR" || key === "PI_HERDR_SUBAGENTS_PARENT_PANE_ID") delete env[key];
|
|
43
|
+
const result = spawnSync(process.env.PI_TEST_BINARY?.trim() || "pi", [...args, "--offline", "--no-mcp"], { cwd: root, env, encoding: "utf8", timeout: 30_000 });
|
|
44
|
+
assert.equal(result.error, undefined, String(result.error));
|
|
45
|
+
assert.equal(result.status, 0, result.stderr || result.stdout);
|
|
46
|
+
const loaded = JSON.parse(readFileSync(resultPath, "utf8"));
|
|
47
|
+
for (const command of ["file-extension", "package-extension", "skill:package-skill", "package-prompt", "parent"]) assert.ok(loaded.commands.includes(command), `Missing ${command}: ${JSON.stringify(loaded)}`);
|
|
48
|
+
assert.deepEqual([...loaded.tools].sort(), ["codemode", "read", "subagent_report"]);
|
|
49
|
+
console.log("PASS real Pi loads explicit file and package sources, including package skills/prompts despite discovery flags, without broadening the tool allowlist");
|
|
50
|
+
} finally {
|
|
51
|
+
rmSync(root, { recursive: true, force: true });
|
|
52
|
+
}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
|
-
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
2
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
5
6
|
import { fileURLToPath } from "node:url";
|
|
6
7
|
import { executeHerdrSubagents } from "../src/tool.ts";
|
|
7
8
|
import { CliHerdrAutomation } from "../src/herdr.ts";
|
|
9
|
+
import { waitFor } from "./wait-for.mjs";
|
|
8
10
|
|
|
9
11
|
if (process.env.HERDR_ENV !== "1" || !process.env.HERDR_WORKSPACE_ID) {
|
|
10
12
|
throw new Error("The real Herdr smoke test must run from a Pi session inside Herdr.");
|
|
@@ -14,6 +16,37 @@ const packageDirectory = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
|
14
16
|
const root = mkdtempSync(join(tmpdir(), "pi-herdr-subagents-real-"));
|
|
15
17
|
const herdr = new CliHerdrAutomation();
|
|
16
18
|
const tabIds = new Set();
|
|
19
|
+
const paneIds = new Set();
|
|
20
|
+
const trace = [];
|
|
21
|
+
const startedAt = performance.now();
|
|
22
|
+
let failed = false;
|
|
23
|
+
let controlledFocus;
|
|
24
|
+
function record(event) {
|
|
25
|
+
trace.push({ elapsedMs: Math.round(performance.now() - startedAt), ...event });
|
|
26
|
+
writeFileSync(join(root, "trace.json"), JSON.stringify(trace, null, 2));
|
|
27
|
+
}
|
|
28
|
+
for (const method of ["createTab", "splitPane", "startAgent", "isTabFocused", "waitForTabUnfocused", "closeTab", "paneExists"]) {
|
|
29
|
+
const original = herdr[method].bind(herdr);
|
|
30
|
+
herdr[method] = async (...args) => {
|
|
31
|
+
if (method === "isTabFocused" && controlledFocus && !controlledFocus.triggered && tabIds.has(args[0])) {
|
|
32
|
+
const focused = spawnSync(process.env.PI_HERDR_SUBAGENTS_HERDR_BINARY?.trim() || "herdr", ["tab", "focus", args[0]], { encoding: "utf8", timeout: 10_000 });
|
|
33
|
+
assert.equal(focused.status, 0, focused.stderr);
|
|
34
|
+
controlledFocus.triggered = true;
|
|
35
|
+
record({ event: "controlled-focus", tabId: args[0], previousWorkspaceId: controlledFocus.previousWorkspaceId, previousTabId: controlledFocus.previousTabId });
|
|
36
|
+
}
|
|
37
|
+
record({ method, phase: "start", args: args.map((arg) => typeof arg === "object" ? { ...arg, signal: undefined } : arg) });
|
|
38
|
+
try {
|
|
39
|
+
const result = await original(...args);
|
|
40
|
+
if (result?.tabId) tabIds.add(result.tabId);
|
|
41
|
+
if (result?.paneId) paneIds.add(result.paneId);
|
|
42
|
+
record({ method, phase: "end", result });
|
|
43
|
+
return result;
|
|
44
|
+
} catch (error) {
|
|
45
|
+
record({ method, phase: "error", error: String(error), stderr: error.stderr });
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
}
|
|
17
50
|
const model = process.env.HERDR_SUBAGENTS_E2E_MODEL?.trim();
|
|
18
51
|
const configurationPaths = {
|
|
19
52
|
globalConfig: join(root, "global-config.json"),
|
|
@@ -30,7 +63,45 @@ function hasAssistantMessage(path) {
|
|
|
30
63
|
});
|
|
31
64
|
}
|
|
32
65
|
|
|
33
|
-
|
|
66
|
+
function restoreTestFocus() {
|
|
67
|
+
if (!controlledFocus?.triggered || controlledFocus.restored) return;
|
|
68
|
+
const restored = spawnSync(process.env.PI_HERDR_SUBAGENTS_HERDR_BINARY?.trim() || "herdr", ["tab", "focus", controlledFocus.previousTabId], { encoding: "utf8", timeout: 10_000 });
|
|
69
|
+
assert.equal(restored.status, 0, restored.stderr);
|
|
70
|
+
const workspace = spawnSync(process.env.PI_HERDR_SUBAGENTS_HERDR_BINARY?.trim() || "herdr", ["workspace", "focus", controlledFocus.previousWorkspaceId], { encoding: "utf8", timeout: 10_000 });
|
|
71
|
+
assert.equal(workspace.status, 0, workspace.stderr);
|
|
72
|
+
controlledFocus.restored = true;
|
|
73
|
+
assertRestoredFocus();
|
|
74
|
+
record({ event: "restored-focus", workspaceId: controlledFocus.previousWorkspaceId, tabId: controlledFocus.previousTabId });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function assertRestoredFocus() {
|
|
78
|
+
const result = spawnSync(process.env.PI_HERDR_SUBAGENTS_HERDR_BINARY?.trim() || "herdr", ["workspace", "list"], { encoding: "utf8", timeout: 10_000 });
|
|
79
|
+
assert.equal(result.status, 0, result.stderr);
|
|
80
|
+
const focused = JSON.parse(result.stdout).result.workspaces.find((workspace) => workspace.focused);
|
|
81
|
+
assert.equal(focused?.workspace_id, controlledFocus.previousWorkspaceId, "The original workspace focus was not restored");
|
|
82
|
+
assert.equal(focused?.active_tab_id, controlledFocus.previousTabId, "The original tab focus was not restored");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function waitForCleanup(result) {
|
|
86
|
+
const signal = AbortSignal.timeout(5_000);
|
|
87
|
+
await waitFor(async () => {
|
|
88
|
+
const run = JSON.parse(readFileSync(join(result.recordDirectory, "run.json"), "utf8"));
|
|
89
|
+
if (run.cleanupError) throw new Error(`Cleanup failed: ${run.cleanupError}`);
|
|
90
|
+
const survivingPaneIds = [];
|
|
91
|
+
for (const task of result.tasks) if (await herdr.paneExists(task.paneId, signal)) survivingPaneIds.push(task.paneId);
|
|
92
|
+
record({ event: "cleanup-observation", run, survivingPaneIds });
|
|
93
|
+
return !run.cleanupPendingTabIds?.length && survivingPaneIds.length === 0 && !existsSync(join(result.recordDirectory, ".state.lock"));
|
|
94
|
+
}, `cleanup of ${result.tabId}`, 5_000);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function runCase(label, names, concurrency, extensions = []) {
|
|
98
|
+
if (concurrency > 1 && process.env.HERDR_SUBAGENTS_E2E_FOCUS_CLEANUP === "1") {
|
|
99
|
+
const workspaces = spawnSync(process.env.PI_HERDR_SUBAGENTS_HERDR_BINARY?.trim() || "herdr", ["workspace", "list"], { encoding: "utf8", timeout: 10_000 });
|
|
100
|
+
assert.equal(workspaces.status, 0, workspaces.stderr);
|
|
101
|
+
const previous = JSON.parse(workspaces.stdout).result.workspaces.find((workspace) => workspace.focused);
|
|
102
|
+
assert.ok(previous?.active_tab_id, "The focused cleanup probe needs an existing focused tab to restore");
|
|
103
|
+
controlledFocus = { previousWorkspaceId: previous.workspace_id, previousTabId: previous.active_tab_id, triggered: false, restored: false };
|
|
104
|
+
}
|
|
34
105
|
const response = await executeHerdrSubagents({
|
|
35
106
|
label,
|
|
36
107
|
concurrency,
|
|
@@ -42,6 +113,7 @@ async function runCase(label, names, concurrency) {
|
|
|
42
113
|
})),
|
|
43
114
|
}, timeout, (update) => {
|
|
44
115
|
if (update.details?.tabId) tabIds.add(update.details.tabId);
|
|
116
|
+
if (update.details?.cleanupError) record({ event: "cleanup-warning", details: update.details, content: update.content });
|
|
45
117
|
}, { cwd: root }, {
|
|
46
118
|
agentsDirectory: join(packageDirectory, "agents"),
|
|
47
119
|
configurationPaths,
|
|
@@ -50,16 +122,33 @@ async function runCase(label, names, concurrency) {
|
|
|
50
122
|
|
|
51
123
|
const result = response.details;
|
|
52
124
|
if (result?.tabId) tabIds.add(result.tabId);
|
|
125
|
+
record({ event: "case-result", label, result, run: result?.recordDirectory ? JSON.parse(readFileSync(join(result.recordDirectory, "run.json"), "utf8")) : undefined });
|
|
53
126
|
assert.equal(response.isError, undefined, response.content?.[0]?.text);
|
|
54
127
|
assert.equal(result.status, "completed");
|
|
55
128
|
assert.equal(result.effectiveConcurrency, concurrency);
|
|
56
129
|
assert.equal(result.tasks.length, names.length);
|
|
57
130
|
assert.equal(new Set(result.tasks.map((task) => task.paneId)).size, names.length);
|
|
58
131
|
|
|
132
|
+
if (controlledFocus) {
|
|
133
|
+
assert.equal(controlledFocus.triggered, true, "The focus probe did not reach cleanup");
|
|
134
|
+
const run = JSON.parse(readFileSync(join(result.recordDirectory, "run.json"), "utf8"));
|
|
135
|
+
assert.ok(run.cleanupPendingTabIds?.includes(result.tabId), "Focused cleanup was not deferred");
|
|
136
|
+
restoreTestFocus();
|
|
137
|
+
}
|
|
138
|
+
await waitForCleanup(result);
|
|
139
|
+
if (controlledFocus) {
|
|
140
|
+
assertRestoredFocus();
|
|
141
|
+
console.log("PASS focused cleanup: deferred while focused, original workspace/tab focus restored, automatic cleanup observed");
|
|
142
|
+
}
|
|
59
143
|
for (const taskResult of result.tasks) {
|
|
60
144
|
const task = JSON.parse(readFileSync(join(taskResult.recordDirectory, "task.json"), "utf8"));
|
|
61
145
|
const report = JSON.parse(readFileSync(join(taskResult.recordDirectory, "report.json"), "utf8"));
|
|
62
146
|
assert.equal(task.status, "completed");
|
|
147
|
+
assert.deepEqual(task.extensions, extensions);
|
|
148
|
+
if (extensions.length) {
|
|
149
|
+
const commands = JSON.parse(readFileSync(join(taskResult.recordDirectory, "extension-loaded.json"), "utf8"));
|
|
150
|
+
assert.ok(commands.includes("skill:smoke-package-skill"), "Explicit package skill did not load in the real child");
|
|
151
|
+
}
|
|
63
152
|
assert.equal(report.status, "completed");
|
|
64
153
|
assert.equal(report.attemptId, task.currentAttemptId);
|
|
65
154
|
assert.equal(hasAssistantMessage(task.sessionFile), true, `${taskResult.name} produced no assistant event`);
|
|
@@ -70,8 +159,38 @@ async function runCase(label, names, concurrency) {
|
|
|
70
159
|
|
|
71
160
|
try {
|
|
72
161
|
await runCase("real-single", ["hello-single"], 1);
|
|
73
|
-
|
|
162
|
+
const packageSource = join(root, "local-package");
|
|
163
|
+
mkdirSync(join(packageSource, "skills", "smoke-package-skill"), { recursive: true });
|
|
164
|
+
writeFileSync(join(packageSource, "package.json"), JSON.stringify({ name: "herdr-smoke-package", pi: { extensions: ["extension.ts"], skills: ["skills"] } }));
|
|
165
|
+
writeFileSync(join(packageSource, "skills", "smoke-package-skill", "SKILL.md"), "---\nname: smoke-package-skill\ndescription: A real Herdr package-loading probe\n---\n# Probe\n");
|
|
166
|
+
writeFileSync(join(packageSource, "extension.ts"), `import { writeFileSync } from "node:fs";
|
|
167
|
+
import { join } from "node:path";
|
|
168
|
+
export default function (pi) {
|
|
169
|
+
pi.on("session_start", () => writeFileSync(join(process.env.PI_HERDR_SUBAGENTS_TASK_DIR, "extension-loaded.json"), JSON.stringify(pi.getCommands().map((command) => command.name))));
|
|
170
|
+
}`);
|
|
171
|
+
writeFileSync(configurationPaths.globalConfig, JSON.stringify({ personas: { analyst: { extensions: [packageSource] } } }));
|
|
172
|
+
await runCase("real-concurrent", ["hello-one", "hello-two"], 2, [packageSource]);
|
|
173
|
+
} catch (error) {
|
|
174
|
+
failed = true;
|
|
175
|
+
const snapshots = [];
|
|
176
|
+
for (const [kind, ids] of [["tab", tabIds], ["pane", paneIds]]) for (const id of ids) {
|
|
177
|
+
const result = spawnSync(process.env.PI_HERDR_SUBAGENTS_HERDR_BINARY?.trim() || "herdr", [kind, "get", id], { encoding: "utf8", timeout: 10_000 });
|
|
178
|
+
snapshots.push({ kind, id, status: result.status, stdout: result.stdout, stderr: result.stderr, error: result.error ? String(result.error) : undefined });
|
|
179
|
+
}
|
|
180
|
+
writeFileSync(join(root, "failure.json"), JSON.stringify({ error: String(error), snapshots }, null, 2));
|
|
181
|
+
console.error(`FAIL real Herdr smoke; preserved diagnostics: ${root}`);
|
|
182
|
+
throw error;
|
|
74
183
|
} finally {
|
|
75
|
-
|
|
76
|
-
|
|
184
|
+
const cleanupErrors = [];
|
|
185
|
+
try { restoreTestFocus(); } catch (error) { cleanupErrors.push(String(error)); }
|
|
186
|
+
for (const tabId of tabIds) await herdr.closeTab(tabId, AbortSignal.timeout(10_000)).catch((error) => {
|
|
187
|
+
if (!/tab[_ -]?not[_ -]?found|unknown tab/i.test(`${error}\n${error.stderr ?? ""}`)) cleanupErrors.push(String(error));
|
|
188
|
+
});
|
|
189
|
+
if (cleanupErrors.length) {
|
|
190
|
+
failed = true;
|
|
191
|
+
writeFileSync(join(root, "teardown-errors.json"), JSON.stringify(cleanupErrors, null, 2));
|
|
192
|
+
console.error(`FAIL smoke teardown; preserved diagnostics: ${root}`);
|
|
193
|
+
process.exitCode = 1;
|
|
194
|
+
}
|
|
195
|
+
if (!failed) rmSync(root, { recursive: true, force: true });
|
|
77
196
|
}
|
|
@@ -26,6 +26,7 @@ import { isAbortError } from "../src/errors.ts";
|
|
|
26
26
|
import { acquireDirectoryLock, withRunStateLock } from "../src/run-state.ts";
|
|
27
27
|
import { childPiArgs } from "../src/live-agent.ts";
|
|
28
28
|
import registerSubagentReport from "../extensions/subagent-report.ts";
|
|
29
|
+
import { waitFor } from "./wait-for.mjs";
|
|
29
30
|
|
|
30
31
|
/** Drives one task through the batch runner and projects its single result like a task-scoped run. */
|
|
31
32
|
async function runSingleTask(prompt, { label, agent, model, tools, systemPrompt, onStalled, ...options } = {}) {
|
|
@@ -581,6 +582,10 @@ try {
|
|
|
581
582
|
|
|
582
583
|
const definition = parseAgentMarkdown(`---\nname: reviewer\ndescription: Review code\nmodel: fake/model\ntools: read, grep\n---\nReview carefully.`, "reviewer.md");
|
|
583
584
|
check("persona frontmatter parsing", definition.name === "reviewer" && definition.model === "fake/model" && definition.tools?.join(",") === "read,grep" && definition.systemPrompt === "Review carefully.");
|
|
585
|
+
const extensionDefinition = parseAgentMarkdown(`---\nname: routed\ndescription: Routed explorer\nextensions: "~/.pi/agent/extensions/gateway-routing.ts", npm:@example/pi-tools@1.0.0\n---\nExplore.`, "routed.md");
|
|
586
|
+
check("persona frontmatter accepts local and npm extension sources", extensionDefinition.extensions?.join(",") === "~/.pi/agent/extensions/gateway-routing.ts,npm:@example/pi-tools@1.0.0");
|
|
587
|
+
const clearedExtensions = parseAgentMarkdown(`---\nname: routed\ndescription: Routed explorer\nextensions: []\n---\nExplore.`);
|
|
588
|
+
check("persona frontmatter can explicitly clear extension sources", Array.isArray(clearedExtensions.extensions) && clearedExtensions.extensions.length === 0);
|
|
584
589
|
const inheritedToolArgs = childPiArgs({ id: "inherited", sessionFile: "/tmp/inherited.jsonl" }, "/tmp/system.md");
|
|
585
590
|
check("child Pi enables codemode when a persona inherits default tools", inheritedToolArgs.includes("builtin:codemode") && inheritedToolArgs[inheritedToolArgs.indexOf("--tools") + 1] === "+codemode");
|
|
586
591
|
const explicitToolArgs = childPiArgs({ id: "explicit", sessionFile: "/tmp/explicit.jsonl", tools: ["read"] }, "/tmp/system.md");
|
|
@@ -633,6 +638,51 @@ try {
|
|
|
633
638
|
const configuredStart = configuredHerdr.calls.find(([name]) => name === "startAgent")[1];
|
|
634
639
|
check("configured persona uses displayed effective values", !configuredRun.isError && configuredStart.args.includes("task/model") && configuredStart.args.includes("high") && configuredStart.args.includes("--skill") && configuredStart.args.includes("project-skill"));
|
|
635
640
|
|
|
641
|
+
const extensionRoot = join(root, "extension-sources");
|
|
642
|
+
const extensionAgents = join(extensionRoot, "agents");
|
|
643
|
+
mkdirSync(extensionAgents, { recursive: true });
|
|
644
|
+
writeFileSync(join(extensionAgents, "routed.md"), `---\nname: routed\ndescription: Routed explorer\naccess: read\ntools: read\nextensions: ./markdown.ts\n---\nExplore.`);
|
|
645
|
+
const extensionPaths = { globalConfig: join(extensionRoot, "global.json"), projectConfig: join(extensionRoot, "project.json"), globalAgents: join(extensionRoot, "missing-global"), projectAgents: join(extensionRoot, "missing-project") };
|
|
646
|
+
const extensionPersona = () => loadSubagentConfiguration(extensionRoot, extensionAgents, undefined, extensionPaths).personas[0];
|
|
647
|
+
check("Markdown extension sources are effective without JSON overrides", extensionPersona()?.extensions?.join(",") === "./markdown.ts" && extensionPersona()?.extensionsSource === "package persona");
|
|
648
|
+
writeFileSync(extensionPaths.globalConfig, JSON.stringify({ personas: { routed: { extensions: ["./global.ts"] } } }));
|
|
649
|
+
check("global JSON replaces Markdown extension sources", extensionPersona()?.extensions?.join(",") === "./global.ts" && extensionPersona()?.extensionsSource === "global persona");
|
|
650
|
+
const extensionSources = ["~/.pi/agent/extensions/gateway-routing.ts", "npm:@example/pi-tools@1.0.0", "./local package"];
|
|
651
|
+
writeFileSync(extensionPaths.projectConfig, JSON.stringify({ personas: { routed: { extensions: extensionSources.map((source) => ` ${source} `) } } }));
|
|
652
|
+
check("project JSON trims and replaces global extension sources", JSON.stringify(extensionPersona()?.extensions) === JSON.stringify(extensionSources) && extensionPersona()?.extensionsSource === "project persona");
|
|
653
|
+
const extensionDependencies = { agentsDirectory: extensionAgents, configurationPaths: extensionPaths };
|
|
654
|
+
const sourceValues = (args) => args.flatMap((value, index) => value === "--extension" ? [args[index + 1]] : []);
|
|
655
|
+
const extensionRunHerdr = fakeHerdr();
|
|
656
|
+
const extensionRun = await executeHerdrSubagents({ tasks: [{ name: "routed", agent: "routed", prompt: "Review" }] }, undefined, undefined, { cwd: extensionRoot }, { ...extensionDependencies, herdr: extensionRunHerdr });
|
|
657
|
+
const extensionStart = extensionRunHerdr.calls.find(([name]) => name === "startAgent")[1];
|
|
658
|
+
check("explicit extension sources follow mandatory child extensions without shell splitting", !extensionRun.isError && extensionStart.args.includes("--no-extensions") && extensionStart.args.includes("--no-skills") && sourceValues(extensionStart.args)[0] === "builtin:codemode" && sourceValues(extensionStart.args)[1].endsWith("/extensions/subagent-report.ts") && JSON.stringify(sourceValues(extensionStart.args).slice(2)) === JSON.stringify(extensionSources) && extensionStart.args.includes("read,codemode,subagent_report"));
|
|
659
|
+
const extensionRecord = JSON.parse(readFileSync(join(extensionRun.details.tasks[0].recordDirectory, "task.json"), "utf8"));
|
|
660
|
+
check("effective extension sources are persisted in durable tasks", JSON.stringify(extensionRecord.extensions) === JSON.stringify(extensionSources));
|
|
661
|
+
const extensionHistory = await listSubagentHistory(extensionRoot);
|
|
662
|
+
check("history exposes saved extension sources", JSON.stringify(extensionHistory[0]?.tasks[0]?.extensions) === JSON.stringify(extensionSources));
|
|
663
|
+
const extensionList = await executeHerdrSubagents({ action: "list" }, undefined, undefined, { cwd: extensionRoot }, extensionDependencies);
|
|
664
|
+
check("persona listing shows extension sources and their precedence", extensionList.content[0].text.includes("npm:@example/pi-tools@1.0.0") && extensionList.content[0].text.includes("project persona"));
|
|
665
|
+
writeFileSync(extensionPaths.projectConfig, JSON.stringify({ personas: { routed: { extensions: [] } } }));
|
|
666
|
+
check("empty project extension list clears lower-precedence sources", extensionPersona()?.extensions?.length === 0 && extensionPersona()?.extensionsSource === "project persona");
|
|
667
|
+
const extensionResumeHerdr = fakeHerdr();
|
|
668
|
+
const extensionResume = await executeHerdrSubagents({ action: "resume", runId: extensionRun.details.runId, task: 1, prompt: "Follow up" }, undefined, undefined, { cwd: extensionRoot }, { ...extensionDependencies, herdr: extensionResumeHerdr });
|
|
669
|
+
const extensionResumeStart = extensionResumeHerdr.calls.find(([name]) => name === "startAgent")[1];
|
|
670
|
+
check("historical restart retains saved sources after configuration changes", !extensionResume.isError && JSON.stringify(sourceValues(extensionResumeStart.args).slice(2)) === JSON.stringify(extensionSources));
|
|
671
|
+
for (const invalidSources of ["npm:example", [""], ["valid", 42], null]) {
|
|
672
|
+
writeFileSync(extensionPaths.projectConfig, JSON.stringify({ personas: { routed: { extensions: invalidSources } } }));
|
|
673
|
+
const invalidSourcesConfig = loadSubagentConfiguration(extensionRoot, extensionAgents, undefined, extensionPaths);
|
|
674
|
+
check(`invalid extension sources are diagnosed: ${JSON.stringify(invalidSources)}`, invalidSourcesConfig.diagnostics.some((message) => message.includes("extensions must be an array of non-empty strings")) && invalidSourcesConfig.personas[0]?.extensions?.join(",") === "./global.ts");
|
|
675
|
+
}
|
|
676
|
+
writeFileSync(extensionPaths.projectConfig, JSON.stringify({ personas: { routed: { extensions: extensionSources } } }));
|
|
677
|
+
const extensionBlockedHerdr = fakeHerdr("blocked");
|
|
678
|
+
const extensionBlocked = await executeHerdrSubagents({ tasks: [{ name: "routed-blocked", agent: "routed", prompt: "Ask a question" }] }, undefined, undefined, { cwd: extensionRoot }, { ...extensionDependencies, herdr: extensionBlockedHerdr });
|
|
679
|
+
const extensionResponseHerdr = fakeHerdr();
|
|
680
|
+
extensionResponseHerdr.isTaskRunning = async () => false;
|
|
681
|
+
const extensionAnswered = await executeHerdrSubagents({ action: "respond", runId: extensionBlocked.details.runId, task: 1, answer: "Use main" }, undefined, undefined, { cwd: extensionRoot }, { ...extensionDependencies, herdr: extensionResponseHerdr });
|
|
682
|
+
const extensionResponseStart = extensionResponseHerdr.calls.find(([name]) => name === "startAgent")?.[1];
|
|
683
|
+
check("blocked response restart retains saved extension sources", !extensionAnswered.isError && extensionResponseStart && JSON.stringify(sourceValues(extensionResponseStart.args).slice(2)) === JSON.stringify(extensionSources));
|
|
684
|
+
check("unconfigured children keep only mandatory extensions", sourceValues(inheritedToolArgs).length === 2 && JSON.stringify(childPiArgs({ id: "inherited", sessionFile: "/tmp/inherited.jsonl", extensions: [] }, "/tmp/system.md")) === JSON.stringify(inheritedToolArgs));
|
|
685
|
+
|
|
636
686
|
const analystHerdr = fakeHerdr();
|
|
637
687
|
analystHerdr.expectConcurrentPrompts(3);
|
|
638
688
|
const analystBatch = await executeHerdrSubagents({ label: "independent-analysis", concurrency: 3, tasks: [
|
|
@@ -761,7 +811,7 @@ try {
|
|
|
761
811
|
check("write-capable batch is forced serial", writerBatch.details.effectiveConcurrency === 1 && writerHerdr.maxActive === 1 && writerHerdr.calls.filter(([name]) => name === "splitPane").length === 1);
|
|
762
812
|
const focusedHerdr = fakeHerdr("focused");
|
|
763
813
|
await executeHerdrSubagents({ tasks: [{ name: "inspect", prompt: "inspect", agent: "explorer" }] }, undefined, undefined, { cwd: root, model: { provider: "fake", id: "model" } }, { agentsDirectory: join(process.cwd(), "agents"), herdr: focusedHerdr });
|
|
764
|
-
await
|
|
814
|
+
await waitFor(() => focusedHerdr.calls.some(([name]) => name === "closeTab"), "focused tab close");
|
|
765
815
|
const focusedCalls = focusedHerdr.calls.map(([name]) => name);
|
|
766
816
|
check("focused completed tab defers cleanup until focus leaves", focusedCalls.indexOf("waitForTabUnfocused") >= 0 && focusedCalls.indexOf("closeTab") > focusedCalls.indexOf("waitForTabUnfocused"));
|
|
767
817
|
const pendingCleanupHerdr = fakeHerdr();
|
|
@@ -774,7 +824,7 @@ try {
|
|
|
774
824
|
const resumeWhilePending = await executeHerdrSubagents({ action: "resume", runId: pendingCleanupRun.details.runId, task: 1, prompt: "too soon" }, undefined, undefined, { cwd: root }, { agentsDirectory: join(process.cwd(), "agents"), herdr: pendingCleanupHerdr });
|
|
775
825
|
check("pending tab cleanup blocks deletion but permits an isolated resume generation", pendingCleanupRecord.cleanupPendingTabIds?.includes(pendingCleanupRun.details.tabId) && cleanupWhilePending.isError && !resumeWhilePending.isError);
|
|
776
826
|
releasePendingCleanup();
|
|
777
|
-
await
|
|
827
|
+
await waitFor(() => !JSON.parse(readFileSync(join(pendingCleanupRun.details.recordDirectory, "run.json"), "utf8")).cleanupPendingTabIds && !existsSync(join(pendingCleanupRun.details.recordDirectory, ".state.lock")), "deferred cleanup state commit");
|
|
778
828
|
const releasedCleanupRecord = JSON.parse(readFileSync(join(pendingCleanupRun.details.recordDirectory, "run.json"), "utf8"));
|
|
779
829
|
check("deferred cleanup merges into the latest run record", releasedCleanupRecord.status === "completed" && !("cleanupPendingTabIds" in releasedCleanupRecord));
|
|
780
830
|
const shutdownDeferredHerdr = fakeHerdr();
|
|
@@ -789,13 +839,13 @@ try {
|
|
|
789
839
|
const shutdownDeferredController = new AbortController();
|
|
790
840
|
await executeHerdrSubagents({ tasks: [{ name: "shutdown-deferred", prompt: "inspect", agent: "explorer" }] }, shutdownDeferredController.signal, undefined, { cwd: root, model: { provider: "fake", id: "model" } }, { agentsDirectory: join(process.cwd(), "agents"), herdr: shutdownDeferredHerdr });
|
|
791
841
|
shutdownDeferredController.abort(new Error("Parent Pi session closed."));
|
|
792
|
-
await
|
|
842
|
+
await waitFor(() => shutdownDeferredHerdr.calls.some(([name]) => name === "closeTab"), "shutdown close of a focused tab");
|
|
793
843
|
check("parent shutdown force-closes a settled focused tab", shutdownDeferredHerdr.calls.some(([name]) => name === "closeTab"));
|
|
794
844
|
const deferredFailureHerdr = fakeHerdr();
|
|
795
845
|
deferredFailureHerdr.isTabFocused = async () => true;
|
|
796
846
|
deferredFailureHerdr.closeTab = async () => { throw new Error("deferred close failed"); };
|
|
797
847
|
const deferredFailure = await executeHerdrSubagents({ tasks: [{ name: "deferred-cleanup", prompt: "inspect", agent: "explorer" }] }, undefined, undefined, { cwd: root, model: { provider: "fake", id: "model" } }, { agentsDirectory: join(process.cwd(), "agents"), herdr: deferredFailureHerdr });
|
|
798
|
-
await
|
|
848
|
+
await waitFor(() => JSON.parse(readFileSync(join(deferredFailure.details.recordDirectory, "run.json"), "utf8")).cleanupError === "deferred close failed" && !existsSync(join(deferredFailure.details.recordDirectory, ".state.lock")), "deferred cleanup error commit");
|
|
799
849
|
const deferredFailureRecord = JSON.parse(readFileSync(join(deferredFailure.details.recordDirectory, "run.json"), "utf8"));
|
|
800
850
|
check("deferred focus cleanup failure remains durably retryable", deferredFailureRecord.cleanupError === "deferred close failed" && deferredFailureRecord.cleanupPendingTabIds?.includes(deferredFailure.details.tabId));
|
|
801
851
|
const deferredSingleHerdr = fakeHerdr();
|
|
@@ -803,7 +853,7 @@ try {
|
|
|
803
853
|
deferredSingleHerdr.closeTab = async () => { throw new Error("single deferred close failed"); };
|
|
804
854
|
const deferredSingleUpdates = [];
|
|
805
855
|
const deferredSingle = await executeHerdrSubagents({ tasks: [{ name: "deferred-single", prompt: "single deferred cleanup", agent: "explorer" }] }, undefined, (update) => deferredSingleUpdates.push(update), { cwd: root, model: { provider: "fake", id: "model" } }, { agentsDirectory: join(process.cwd(), "agents"), herdr: deferredSingleHerdr });
|
|
806
|
-
await
|
|
856
|
+
await waitFor(() => deferredSingleUpdates.some((update) => update.details.cleanupError) && !existsSync(join(deferredSingle.details.recordDirectory, ".state.lock")), "deferred cleanup error notification");
|
|
807
857
|
const deferredSingleRun = JSON.parse(readFileSync(join(deferredSingle.details.recordDirectory, "run.json"), "utf8"));
|
|
808
858
|
check("deferred cleanup failure is persisted and surfaced without changing completion", deferredSingleRun.status === "completed" && deferredSingleRun.cleanupError === "single deferred close failed" && deferredSingleUpdates.some((update) => update.content[0].text.includes("cleanup failed") && update.details.cleanupError));
|
|
809
859
|
const cancelledHerdr = fakeHerdr();
|
|
@@ -1791,13 +1841,14 @@ try {
|
|
|
1791
1841
|
const staleContinuationTask = join(staleContinuationRun, "tasks", "01-task");
|
|
1792
1842
|
mkdirSync(staleContinuationTask, { recursive: true });
|
|
1793
1843
|
writeFileSync(join(staleContinuationRun, "run.json"), JSON.stringify({ id: "stale-continuation", label: "stale-continuation", status: "running", cwd: staleContinuationRoot, effectiveConcurrency: 1, tabId: "w1:t-old", paneIds: ["w1:p-old"], startedAt: new Date().toISOString() }));
|
|
1794
|
-
writeFileSync(join(staleContinuationTask, "task.json"), JSON.stringify({ id: "stale-task", runId: "stale-continuation", order: 1, name: "stale-task", status: "queued", prompt: "continue", agent: "explorer", skills: [], sessionFile: join(staleContinuationTask, "session.jsonl") }));
|
|
1844
|
+
writeFileSync(join(staleContinuationTask, "task.json"), JSON.stringify({ id: "stale-task", runId: "stale-continuation", order: 1, name: "stale-task", status: "queued", prompt: "continue", agent: "explorer", skills: [], extensions: extensionSources, sessionFile: join(staleContinuationTask, "session.jsonl") }));
|
|
1795
1845
|
writeFileSync(join(staleContinuationTask, "system-prompt.md"), "Report the result.");
|
|
1796
1846
|
const staleContinuationHerdr = fakeHerdr();
|
|
1797
1847
|
staleContinuationHerdr.paneExists = async () => false;
|
|
1798
1848
|
staleContinuationHerdr.createTab = async (request) => { staleContinuationHerdr.calls.push(["createTab", request]); return { tabId: "w1:t-new", paneId: "w1:p-new" }; };
|
|
1799
1849
|
await continueQueuedRun(staleContinuationRun, staleContinuationHerdr);
|
|
1800
1850
|
const staleContinuationRecord = JSON.parse(readFileSync(join(staleContinuationRun, "run.json"), "utf8"));
|
|
1851
|
+
check("queued recovery retains saved extension sources", JSON.stringify(sourceValues(staleContinuationHerdr.calls.find(([name]) => name === "startAgent")[1].args).slice(2)) === JSON.stringify(extensionSources));
|
|
1801
1852
|
check("queued recovery closes a replaced stale tab", staleContinuationRecord.status === "completed" && staleContinuationRecord.tabId === "w1:t-new" && !staleContinuationRecord.staleTabIds && staleContinuationHerdr.calls.some(([name, request]) => name === "closeTab" && request.tabId === "w1:t-old"));
|
|
1802
1853
|
const failedCleanupRoot = join(root, "stale-continuation-cleanup-failure");
|
|
1803
1854
|
const failedCleanupRun = join(failedCleanupRoot, ".pi", "herdr-subagents", "runs", "run");
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
2
|
+
|
|
3
|
+
/** Wait for an observable condition, not an assumed completion time. */
|
|
4
|
+
export async function waitFor(check, description, timeoutMs = 5_000) {
|
|
5
|
+
const deadline = performance.now() + timeoutMs;
|
|
6
|
+
for (;;) {
|
|
7
|
+
const result = await check();
|
|
8
|
+
if (result) return result;
|
|
9
|
+
if (performance.now() >= deadline) throw new Error(`Timed out after ${timeoutMs} ms waiting for ${description}`);
|
|
10
|
+
await delay(10);
|
|
11
|
+
}
|
|
12
|
+
}
|