@maxiaochao/pi-toolkit 0.10.21 → 0.10.22

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.
Files changed (31) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/package.json +1 -1
  3. package/packages/herdr-subagents/CHANGELOG.md +19 -0
  4. package/packages/herdr-subagents/README.md +16 -8
  5. package/packages/herdr-subagents/RESUME-LIFECYCLE-DESIGN.md +84 -0
  6. package/packages/herdr-subagents/TESTING.md +13 -3
  7. package/packages/herdr-subagents/extensions/herdr-subagents.ts +4 -17
  8. package/packages/herdr-subagents/extensions/subagent-report.ts +2 -2
  9. package/packages/herdr-subagents/package.json +1 -1
  10. package/packages/herdr-subagents/src/batch-runner.ts +56 -15
  11. package/packages/herdr-subagents/src/blocking.ts +115 -72
  12. package/packages/herdr-subagents/src/continue-run.ts +22 -7
  13. package/packages/herdr-subagents/src/fleet.ts +2 -1
  14. package/packages/herdr-subagents/src/herdr.ts +10 -1
  15. package/packages/herdr-subagents/src/historical-resume.ts +184 -0
  16. package/packages/herdr-subagents/src/history.ts +5 -131
  17. package/packages/herdr-subagents/src/live-agent.ts +99 -35
  18. package/packages/herdr-subagents/src/parameters.ts +2 -2
  19. package/packages/herdr-subagents/src/protocol.ts +3 -1
  20. package/packages/herdr-subagents/src/reconcile.ts +156 -135
  21. package/packages/herdr-subagents/src/records.ts +50 -32
  22. package/packages/herdr-subagents/src/resume-state.ts +46 -0
  23. package/packages/herdr-subagents/src/run-pane-allocator.ts +39 -37
  24. package/packages/herdr-subagents/src/run-state.ts +120 -0
  25. package/packages/herdr-subagents/src/status.ts +0 -6
  26. package/packages/herdr-subagents/src/tab-cleanup.ts +16 -16
  27. package/packages/herdr-subagents/src/tool.ts +1 -54
  28. package/packages/herdr-subagents/src/validation.ts +1 -1
  29. package/packages/herdr-subagents/tests/extension-contract.ts +4 -4
  30. package/packages/herdr-subagents/tests/run-tests.mjs +556 -60
  31. package/packages/herdr-subagents/src/runner.ts +0 -234
package/CHANGELOG.md CHANGED
@@ -1,3 +1,10 @@
1
+ ## 2026-10-12 - v0.10.22
2
+
3
+ - bundle herdr-subagents v0.4.8: make blocking behave identically in serial and parallel runs and let respond continue a named failed task from its saved session
4
+ - bundle Herdr Subagents runtime telemetry and concurrent historical-resume lifecycle fixes without conflating Herdr UI blocking with business blocking
5
+ - bundle Herdr Subagents failed-task continuation: answer a named failed task from its saved Pi session while its batch still runs, without closing shared tabs or restarting queued siblings
6
+ - bundle the Herdr Subagents three-outcome child contract (`completed`, `blocked`, `failed`): blocking now works identically in serial and parallel runs, keeps queued work with the batch that owns it, and always needs an explicit task number to answer
7
+
1
8
  ## 2026-10-10 - v0.10.21
2
9
 
3
10
  - bundle herdr-subagents v0.4.7: fix blocked-run scheduling and limit interactive blocking to serial runs
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maxiaochao/pi-toolkit",
3
- "version": "0.10.21",
3
+ "version": "0.10.22",
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": [
@@ -1,3 +1,22 @@
1
+ ## 2026-10-12 - v0.4.8
2
+
3
+ - make blocking behave identically in serial and parallel runs and let respond continue a named failed task from its saved session
4
+ - add durable Herdr runtime telemetry without treating UI `blocked` as business blocked; preserve late reports after delivery-unknown prompt timeouts and fail only explicit terminal paths without a valid report
5
+ - allow different historical tasks in one run to resume concurrently in a shared tab while fencing task writers, aggregate state, and final cleanup
6
+ - reserve `retrying` display support without fabricating retries from ambiguous Pi lifecycle events
7
+ - remove the unreachable single-task execution path so every task settles through the batch runner; legacy top-level prompts are still normalized into one-item task arrays
8
+ - settle a batch through the run lock so a concurrent writer's `run.json` changes are no longer overwritten from a stale in-memory copy
9
+ - let `respond` continue an explicitly named failed task from its saved Pi session, reusing its live pane when one survives, while its batch is still running
10
+ - keep a settling batch from finishing the run or closing its tab while a continued task is still working, and from restarting queued siblings it still owns
11
+ - claim a continued task before any pane work so a sibling that settles first cannot close the tab the continuation is about to use
12
+ - report a still-queued sibling as running work instead of inventing an input request with no question
13
+ - require the task number on every `respond` so an answer never selects a task implicitly
14
+ - rename the child report status `needs-input` to `blocked` and reduce the child contract to `completed`, `blocked`, and `failed`
15
+ - let a task block at any concurrency instead of downgrading concurrent input requests to `missing_input` failures, and drop `failureKind`/`requiredInput`
16
+ - give a batch sole ownership of its queued tasks so answering one blocked lane can no longer start a queued sibling the batch is about to run
17
+ - settle a batch against the task records for any lane that was answered while its siblings were still running, instead of reporting a stale question and keeping its tab open
18
+ - settle a continued run's status through the run lock instead of overwriting it from a stale copy
19
+
1
20
  ## 2026-10-10 - v0.4.7
2
21
 
3
22
  - fix blocked-run scheduling and limit interactive blocking to serial runs
@@ -78,21 +78,29 @@ The snapshot reports the aggregate run state and every task's state, including a
78
78
 
79
79
  Run controls accept the complete run UUID, the saved run-directory name, or any unique UUID prefix. Results always return the canonical complete UUID.
80
80
 
81
- A child missing required information reports `needs-input` with an exact question. The run becomes blocked, keeps its Herdr tab, pane, partial report, and Pi session, and returns control to the parent. Answer through the control tool to continue the original session:
81
+ Every child reports exactly one of three outcomes. `completed` and `failed` are terminal; `blocked` means the child is still alive in its pane but needs the parent agent to answer or decide something before it can go on. A blocked task keeps its Herdr tab, pane, partial report, and Pi session, and returns control to the parent with an exact question. Answer through the control tool to continue the original session:
82
82
 
83
83
  ```text
84
- herdr_subagents_control({ action: "respond", runId: "<run-id>", answer: "Use the main branch." })
84
+ herdr_subagents_control({ action: "respond", runId: "<run-id>", task: 1, answer: "Use the main branch." })
85
85
  ```
86
86
 
87
87
  Blocked is a normal control state, not a failed tool call. Only terminal failure, cancellation, or a partial aggregate is returned as a tool error.
88
88
 
89
- Blocking is limited to effectively serial runs (`effectiveConcurrency: 1`), where task order can depend on the answer. In a concurrent read-only run, tasks must be independent: a task that requests input is recorded as `failed` with `failureKind: "missing_input"` and `requiredInput`, while its independent siblings continue. This prevents a blocked concurrent task from holding the parent tool call behind unrelated long-running siblings.
89
+ Blocking works the same way at any concurrency: a blocked task never holds up its independent siblings, which keep running and settling normally. A blocked run does keep the dispatcher slot until you respond or cancel it, so another foreground run started meanwhile is refused with the blocking run ID and a background run stays queued. That is what keeps a second write-capable run from editing the repository behind an unanswered question.
90
90
 
91
- Only work owned by the current parent session's main dispatcher can remain blocked. Historical follow-ups that need more input settle as `missing_input` failures, and startup reconciliation cancels blocked work whose parent session disappeared; neither path creates an unowned blocked run.
91
+ A failed task is not a dead end. Name it explicitly to continue its original Pi session with another message, even while the rest of its batch is still running:
92
+
93
+ ```text
94
+ herdr_subagents_control({ action: "respond", runId: "<run-id>", task: 2, answer: "Use the staging credentials and retry." })
95
+ ```
96
+
97
+ The task resumes in its own live pane when that pane still hosts its child, and otherwise restarts from the saved session in a new pane. `task` is always required so an answer never picks a task implicitly. A task whose child never produced a saved Pi session cannot be continued and says so; submit a new run instead. Continuing a failed task never starts its batch's queued siblings, which the running batch still owns. While a continued task is working, its run stays `running` and its Herdr tab stays open even if the batch that created it has already settled.
98
+
99
+ Blocked work always belongs to a live parent session. A historical follow-up may block too, keeping its shared resume tab until you answer it. Startup reconciliation cancels blocked work whose parent session disappeared, so no blocked run is ever left without an owner.
92
100
 
93
101
  Cancel one active or queued run with `herdr_subagents_control({ action: "cancel", runId: "<run-id>" })`. Cancellation aborts its live child prompts, archives active or queued tasks as cancelled, closes its run tab, and leaves unrelated runs alone.
94
102
 
95
- While a batch is active, a compact Fleet monitor stays below the editor. Its divider/title row summarizes the run, and each task row shows a hollow or filled status circle, the real persona and task name, status, live active duration, and cumulative child Pi token usage in one compact shared metrics column after the longest row rather than at the terminal edge. Running work uses Pi's warning/yellow semantic color and completed work uses success/green. Token totals use the child session's persisted assistant, tool-result, standalone usage, compaction, and branch-summary usage; they are never estimated. Queued tasks show neither fabricated time nor tokens. The monitor follows Pi theme semantics, truncates by visible terminal columns, and refreshes session usage from both file events and the running one-second timer.
103
+ While a batch is active, a compact Fleet monitor stays below the editor. Its divider/title row summarizes the run, and each task row shows a hollow or filled status circle, the real persona and task name, business status or current runtime telemetry, live active duration, and cumulative child Pi token usage in one compact shared metrics column after the longest row rather than at the terminal edge. Herdr `blocked` is shown neutrally as `waiting_interaction` while the task remains `running`; only a child `subagent_report` with `status=blocked` makes the business task `blocked`. Prompt-delivery timeouts appear as `delivery_unknown` while a working live child is still allowed to publish its authoritative report. Running work uses Pi's warning/yellow semantic color and completed work uses success/green. Token totals use the child session's persisted assistant, tool-result, standalone usage, compaction, and branch-summary usage; they are never estimated. Queued tasks show neither fabricated time nor tokens. The monitor follows Pi theme semantics, truncates by visible terminal columns, and refreshes session usage from both file events and the running one-second timer.
96
104
 
97
105
  With an empty editor, press `↓` to enter the list, `↑`/`↓` to choose a task, `Enter` to focus its exact pane, and `Esc` to return. `/herdr-subagents focus <task-number>` remains available as a fallback. Inside every child Agent, press `Alt+P` or run `/parent` to return to the exact parent Agent pane; the shortcut is also shown in the child footer.
98
106
 
@@ -100,7 +108,7 @@ With an empty editor, press `↓` to enter the list, `↑`/`↓` to choose a tas
100
108
 
101
109
  Run `/herdr-subagents history` or `herdr_subagents_control({ action: "history" })` to list the full compact project-local run and task archive after Herdr tabs close. History does not accept a run ID and is not a status interface. Full results, described documents, artifacts, and persistent Pi sessions remain separate on disk and are not loaded into parent context.
102
110
 
103
- Continue a saved conversation in a new Herdr tab with `herdr_subagents_control({ action: "resume", runId: "<run-id>", task: 1, prompt: "Follow up..." })`. Remove only a selected archive with `herdr_subagents_control({ action: "cleanup", runId: "<run-id>" })`. History has no automatic count-based eviction. The extension adds the runtime path to local Git excludes when available (and otherwise uses `.pi/.gitignore`) without overwriting unrelated rules.
111
+ Continue a saved conversation with `herdr_subagents_control({ action: "resume", runId: "<run-id>", task: 1, prompt: "Follow up..." })`. Different settled tasks from the same run may resume concurrently in distinct panes of one shared resume tab; the same task remains single-writer and rejects a second resume. Remove only a selected archive with `herdr_subagents_control({ action: "cleanup", runId: "<run-id>" })`. History has no automatic count-based eviction. The extension adds the runtime path to local Git excludes when available (and otherwise uses `.pi/.gitignore`) without overwriting unrelated rules.
104
112
 
105
113
  Read-only tasks start FIFO up to the configured concurrency limit. A pane is created only when its task starts, belongs exclusively to that task, and is never reused. Completed panes remain inspectable while another task in the batch is unsettled. Results remain in input order, and one failed task does not cancel its siblings. Any batch containing a write-capable persona is forced to concurrency one.
106
114
 
@@ -112,7 +120,7 @@ Foreground calls wait until all children report completion or a request for inpu
112
120
 
113
121
  The child must finish through the package's structured `subagent_report` tool. Every prompt gets a unique attempt ID; the child commits to an attempt-specific report, and the parent accepts only the matching report before updating the canonical task result. A late report from an older retry, response, or historical follow-up cannot settle a newer attempt. The parent receives only:
114
122
 
115
- - completion or failure status
123
+ - `completed`, `blocked`, or `failed` status
116
124
  - a concise summary
117
125
  - paths to useful documents
118
126
  - the task-record location
@@ -299,7 +307,7 @@ Run `/herdr-subagents agents`, `/herdr-subagents models`, or `/herdr-subagents s
299
307
 
300
308
  Queued background submissions are persisted before dispatch. Closing the parent Pi session cancels queued, running, and blocked work, records `cancelled`, and closes the owned run tab. On the next Pi start, stale active records and queued submissions from a prior parent session are reconciled to a terminal state, including when Pi reused the same process, and leaked tabs are cleaned up when possible. Missing, truncated, or structurally invalid child reports become explicit failures or cancellation diagnostics; recovery errors are reported without leaving new submissions paused.
301
309
 
302
- Tab, pane, and child startup commands are not automatically retried: an error may arrive after Herdr already created the resource. A failure after prompt submission is preserved and never automatically rerun. Historical follow-ups exclude concurrent resumes of the same run with an atomic `.resume.lock` directory; if Pi crashes without releasing it, confirm no Agent is still using that run before removing the lock manually. `stalledWarningSeconds` emits a warning without killing long-running work. Run/task/report snapshots use atomic replacement so interruption cannot expose half-written JSON.
310
+ 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.
303
311
 
304
312
  The child does not inherit parent extensions, parent conversation, or unconfigured skills, but normal project context files still apply. It uses the same Pi installation, provider configuration, model catalog, and credentials as the parent.
305
313
 
@@ -0,0 +1,84 @@
1
+ # Historical resume and Herdr lifecycle design
2
+
3
+ ## Invariants
4
+
5
+ - A task session has one writer. `tasks/<task>/.resume.lock` is held for the whole
6
+ historical turn, so a second resume of that task fails immediately.
7
+ - Different settled tasks in one run may resume concurrently. They share one new
8
+ resume tab and each owns a distinct pane.
9
+ - `run.json` is changed only while holding the short-lived `run/.state.lock`.
10
+ The lock covers shared tab allocation, active-resume membership, aggregate
11
+ status, and final cleanup ownership; it never covers an Agent turn.
12
+ - A structured `subagent_report` is the authority for business completion,
13
+ failure, or `needs-input`. Herdr UI state is runtime telemetry only.
14
+
15
+ ## Durable resume state
16
+
17
+ `run.json.resume` contains the shared resume tab identity and an `active` map
18
+ from task id to pane, generation, attempt, owner identity/token, and phase. The
19
+ first entrant creates the tab while holding the run lock and uses its root pane.
20
+ Later entrants split a pane in that tab. Allocation is first persisted as
21
+ `allocating`; after task/turn records durably identify the attempt it advances to
22
+ `running`. Reconciliation can therefore fail an abandoned allocation or promote
23
+ a fully persisted attempt after a crash at either boundary.
24
+
25
+ When a turn settles, its task/report/turn are persisted first. Under the run
26
+ lock, the task is then removed from `resume.active`, all task files are read to
27
+ derive the run status, and the caller that observes an empty map claims final
28
+ tab cleanup. A failed turn removes only itself; siblings keep running. A
29
+ `needs-input` turn stays represented by its blocked task/pane until `respond`
30
+ settles it. Tab cleanup occurs only when no resume task is running or blocked.
31
+
32
+ The run lock prepares its owner metadata in a private directory and atomically
33
+ publishes the complete lock directory. Contenders wait with abort support. A
34
+ legacy ownerless initialization has an explicit expiry lease and is reclaimed
35
+ only after directory-identity revalidation; a dead-owner lock is atomically
36
+ renamed away before retry. Task locks use the same stale-owner recovery but
37
+ contention is reported instead of waited on.
38
+
39
+ ## Recovery and cancellation
40
+
41
+ Reconciliation validates each `resume.active` member against its attempt and
42
+ task record: dead panes/processes become failed or cancelled using the existing
43
+ attempt failure rules, stale members are removed under the run lock, and the
44
+ last remover atomically claims the generation's tab cleanup. Cancellation is
45
+ member-scoped by owner and attempt, so one parent cannot cancel a sibling
46
+ owner's turn. Every `run.json` reconciliation/cancellation update is a locked
47
+ read-modify-write.
48
+
49
+ This recovery is best effort, not complete. Herdr 0.9.2 provides no idempotency
50
+ key for tab creation or pane splitting. If the process receives `SIGKILL` after
51
+ Herdr creates the external resource but before its ID is durably persisted, the
52
+ resource cannot be identified by this state machine and may remain orphaned.
53
+
54
+ ## Herdr runtime projection
55
+
56
+ `agent prompt --wait` can return `idle`, `done`, or `blocked`; `agent get` exposes
57
+ `working`, `blocked`, `idle`, `done`, or `unknown`. While a prompt is pending we
58
+ poll that state and persist/emit a separate runtime state:
59
+
60
+ - `working` -> `working`
61
+ - `blocked` -> `waiting_interaction` (Task remains `running`)
62
+ - `agent_prompt_stalled`/timeout -> `delivery_unknown`; while the attempt state
63
+ is `working` and the child process is alive, wait for its report and never resend
64
+ - `idle`/`done` with a valid report -> use the report's business terminal state
65
+ - `idle`/`done` without a report after a short file-publication grace -> Task `failed`
66
+ - prompt/process/API terminal error without a valid report -> Task `failed`
67
+ - `agent get` query failure -> diagnostic only; continue waiting for report or
68
+ independent pane/process evidence
69
+
70
+ When a scoped report appears, its waiter aborts and explicitly awaits the
71
+ outstanding `agent prompt --wait` command before returning. Runtime writes
72
+ revalidate the current attempt and active task status, so late transport
73
+ telemetry cannot overwrite a report-derived terminal state.
74
+
75
+ Herdr's `blocked` is not converted to business Task `blocked`; only a valid
76
+ `subagent_report(status="needs-input")` does that. Runtime state, pane id, and a
77
+ short diagnostic are exposed in task records, activity, and Fleet without replacing
78
+ Task status.
79
+
80
+ `retrying` is reserved in the runtime schema and Fleet renderer but is not emitted
81
+ by the current child extension. Herdr has no retrying state, and Pi's public
82
+ extension lifecycle does not expose an auto-retry hook or a reliable `willRetry`
83
+ field. Inferring it from `agent_end`/`agent_start` would also label ordinary queued
84
+ continuations as retries, so the implementation deliberately does not fabricate it.
@@ -18,7 +18,9 @@
18
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
19
  - Verify all-success foreground batches still wait for every task and explicit `background: true` submissions still return immediately.
20
20
  - Verify a blocked dispatcher owner immediately rejects both newly submitted and already-queued foreground runs with its full run ID, keeps explicit background runs queued, and removes queued foreground runs when their caller cancels.
21
- - Verify only effectively serial runs can block; concurrent input requests settle as structured `missing_input` failures, including owner-loss reconciliation, while independent siblings continue.
21
+ - Verify a parallel task blocks exactly like a serial one, keeping its question, attempt-scoped report, and tab while an independent sibling completes, that answering it settles and closes the run, and that reconciliation treats a blocked report identically at any concurrency.
22
+ - Verify two lanes of one parallel batch can block at once, that each question is exposed in turn as they are answered, and that the run closes only after the last answer.
23
+ - Answer a blocked lane while a sibling of the same parallel batch is still running and verify the batch keeps sole ownership of its queued tasks, runs them exactly once, and then settles and closes its tab against the task records rather than its own stale blocked snapshot, carrying no lifecycle field the blocked record left behind.
22
24
  - Verify historical follow-ups cannot create unowned blocked work and owner-loss reconciliation cancels serial blocked work instead of reconstructing hidden dispatcher ownership.
23
25
  - Verify all paths use interactive Agent start/prompt without print mode, shell injection, markers, or pane reuse.
24
26
  - Force write-capable batches to serial execution while allowing bounded read-only parallelism, including three concurrent `analyst` tasks with independent task-level models.
@@ -29,10 +31,18 @@
29
31
  - Verify live token updates are file-event driven and polled on each running one-second tick, elapsed repainting exists only while work runs, and widget disposal closes watchers and timers.
30
32
  - Verify selected active/queued run cancellation, blocked-response live Fleet updates, authoritative stale-field replacement, parent-shutdown cancellation, idempotent missing-tab cleanup, cleanup notifications, atomic state replacement, non-retried uncertain startup operations, and stalled warnings.
31
33
  - Verify `status(runId)` isolates one run, projects queue-only state, prefers durable state over a stale queue record, exposes blocked questions, marks terminal states settled, rejects unknown IDs, and leaves full `history` semantics unchanged.
32
- - Verify resumed batches retain newly allocated pane IDs and historical follow-ups exclude simultaneous session writers.
34
+ - Verify historical follow-ups allow different tasks to share one resume tab while preserving one session writer per task, and active resume membership keeps aggregate status running before the sibling task record is persisted.
35
+ - Verify Herdr `blocked` projects only `waiting_interaction`, prompt-delivery timeout projects `delivery_unknown` and accepts a late report, terminal API/process errors without reports fail, and `retrying` remains a display-only running runtime state until a reliable lifecycle source exists.
33
36
  - 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.
34
37
  - 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.
38
+ - Verify settling a batch merges run state written concurrently under the run lock instead of overwriting `run.json` from a stale in-memory copy.
39
+ - 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.
40
+ - 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.
41
+ - 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.
42
+ - Verify a sibling that is still queued is reported as running work rather than as an invented input request with no question.
43
+ - Verify a continued task may ask for input, becomes an ordinary blocked task, and is then answerable without naming its number.
44
+ - Verify `respond` refuses completed, cancelled, queued, running, and nonexistent tasks without touching Herdr or the run record.
35
45
 
36
46
  ## Latest baseline
37
47
 
38
- All 198 deterministic scheduler, recovery, process-boundary, schema-validation, navigation, metrics, responsive-formatting, lifecycle, attempt-isolation, 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 a read-only package inspection with matching durable attempt/report IDs and automatic tab cleanup.
48
+ All 246 deterministic scheduler, recovery, process-boundary, schema-validation, navigation, metrics, responsive-formatting, lifecycle, attempt-isolation, 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 a read-only package inspection with matching durable attempt/report IDs and automatic tab cleanup.
@@ -1,7 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { fileURLToPath } from "node:url";
3
3
  import type { AgentToolResult, ExtensionAPI, ExtensionCommandContext, Theme, ToolRenderResultOptions } from "@earendil-works/pi-coding-agent";
4
- import { Container, Editor, Spacer, Text } from "@earendil-works/pi-tui";
4
+ import { Container, Editor, Text } from "@earendil-works/pi-tui";
5
5
  import { loadSubagentConfiguration } from "../src/config.ts";
6
6
  import { CliHerdrAutomation, type HerdrAutomation } from "../src/herdr.ts";
7
7
  import { activeRunText } from "../src/monitor.ts";
@@ -9,7 +9,7 @@ import { RunDispatcher } from "../src/dispatcher.ts";
9
9
  import { historyText, listSubagentHistory } from "../src/history.ts";
10
10
  import { cancelQueuedRuns, loadQueuedRuns, persistQueuedRun, removeQueuedRun } from "../src/queued-runs.ts";
11
11
  import { cancelActiveSubagentRuns, reconcileSubagentRuns } from "../src/reconcile.ts";
12
- import { executeHerdrSubagents, executeHerdrSubagentsControl, type HerdrSubagentsBatchDetails, type HerdrSubagentsControlToolParams, type HerdrSubagentsDetails, type HerdrSubagentsRunParams } from "../src/tool.ts";
12
+ import { executeHerdrSubagents, executeHerdrSubagentsControl, type HerdrSubagentsBatchDetails, type HerdrSubagentsControlToolParams, type HerdrSubagentsRunParams } from "../src/tool.ts";
13
13
  import { HerdrSubagentsControlParams, HerdrSubagentsParams } from "../src/parameters.ts";
14
14
  import { HERDR_ACTIONS } from "../src/validation.ts";
15
15
  import { FleetController, type FleetWidgetRuntime } from "../src/fleet-controller.ts";
@@ -62,19 +62,6 @@ function renderToolResult(result: AgentToolResult<unknown>, _options: ToolRender
62
62
  for (const task of batch.tasks) container.addChild(new Text(`${statusVisual(task.status).glyph} ${task.name}: ${task.summary}`, 0, 0));
63
63
  return container;
64
64
  }
65
- const details = result.details as HerdrSubagentsDetails | undefined;
66
- if (details?.agent) {
67
- const container = new Container();
68
- container.addChild(new Text(`${theme.fg("toolTitle", theme.bold(details.agent))} ${theme.fg(statusVisual(details.status).color, details.status)}`, 0, 0));
69
- container.addChild(new Text(details.summary, 0, 0));
70
- if (details.documents.length > 0) {
71
- container.addChild(new Spacer(1));
72
- for (const document of details.documents) container.addChild(new Text(theme.fg("muted", `${document.description}: ${document.path}`), 0, 0));
73
- }
74
- if (details.recordDirectory) container.addChild(new Text(theme.fg("dim", `Record: ${details.recordDirectory}`), 0, 0));
75
- if (details.errorMessage) container.addChild(new Text(theme.fg("error", `Error: ${details.errorMessage}`), 0, 0));
76
- return container;
77
- }
78
65
  const content = result.content[0];
79
66
  return new Text(content?.type === "text" ? content.text : "(no output)", 0, 0);
80
67
  }
@@ -195,7 +182,7 @@ export function registerHerdrSubagents(pi: ExtensionAPI, dependencies: HerdrSuba
195
182
  pi.registerTool({
196
183
  name: "herdr_subagents",
197
184
  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. Only effectively serial runs can pause for input; concurrent input requests become missing_input failures while independent siblings continue. 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.",
185
+ 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. A task that needs the parent agent to answer or decide something reports blocked at any concurrency, keeps its live pane, and lets its independent siblings continue; answer it with herdr_subagents_control respond, which a blocked run waits for before another foreground run can start. 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
186
  promptSnippet: "Run focused work in visible Herdr subagents",
200
187
  parameters: HerdrSubagentsParams,
201
188
  prepareArguments: normalizeHerdrSubagentsArguments,
@@ -328,7 +315,7 @@ export function registerHerdrSubagents(pi: ExtensionAPI, dependencies: HerdrSuba
328
315
  pi.registerTool({
329
316
  name: "herdr_subagents_control",
330
317
  label: "Herdr Subagents Control",
331
- description: "Inspect Herdr personas, query one run by ID, list run history, cancel active or queued work, answer a blocked task, resume a saved task, or remove a selected archived run. Use status for one run and history only for the full archive. Use herdr_subagents to start new work.",
318
+ description: "Inspect Herdr personas, query one run by ID, list run history, cancel active or queued work, answer a blocked task or continue a failed one with respond, resume a saved task, or remove a selected archived run. Continuing a failed task requires its task number and works while the rest of its batch still runs. Use status for one run and history only for the full archive. Use herdr_subagents to start new work.",
332
319
  parameters: HerdrSubagentsControlParams,
333
320
 
334
321
  async execute(_toolCallId, params: HerdrSubagentsControlToolParams, signal, onUpdate, ctx) {
@@ -23,7 +23,7 @@ async function returnToParent(pi: ExtensionAPI, ctx: { ui: { notify(message: str
23
23
  }
24
24
 
25
25
  const ReportParams = Type.Object({
26
- status: StringEnum(["completed", "needs-input", "failed"] as const, { description: "Current outcome for this task." }),
26
+ status: StringEnum(["completed", "blocked", "failed"] as const, { description: "Outcome of this turn. completed: the task is finished. blocked: you have a specific question or decision the parent agent must answer before you can continue, and the run should stay open waiting for it. failed: this task cannot be completed, so the run should not wait for an answer." }),
27
27
  summary: Type.String({ minLength: 1, description: "One concise paragraph for the parent agent." }),
28
28
  result: Type.String({ description: "Complete final result. Stored on disk and excluded from the parent context." }),
29
29
  documents: Type.Optional(Type.Array(Type.Object({
@@ -31,7 +31,7 @@ const ReportParams = Type.Object({
31
31
  description: Type.String({ minLength: 1, description: "Short description of the document." }),
32
32
  }))),
33
33
  error: Type.Optional(Type.String({ minLength: 1, description: "Failure detail when status is failed." })),
34
- question: Type.Optional(Type.String({ minLength: 1, description: "Exact question when status is needs-input." })),
34
+ question: Type.Optional(Type.String({ minLength: 1, description: "Exact question for the parent agent when status is blocked." })),
35
35
  });
36
36
 
37
37
  function writeAtomic(path: string, content: string): void {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maxiaochao/pi-herdr-subagents",
3
- "version": "0.4.7",
3
+ "version": "0.4.8",
4
4
  "description": "Run inspectable interactive Pi subagent batches through Herdr.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -3,17 +3,18 @@ import { mkdir, writeFile } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import { CliHerdrAutomation, type HerdrAutomation } from "./herdr.ts";
5
5
  import type { PersonaAccess } from "./config.ts";
6
- import type { HerdrSubagentsDocument } from "./runner.ts";
7
6
  import { ensureRuntimeIgnored } from "./history.ts";
8
7
  import { liveAgentName, promptLiveAgent } from "./live-agent.ts";
9
8
  import { writeJsonAtomic } from "./state.ts";
10
9
  import type { OwnerIdentity } from "./ownership.ts";
11
10
  import { ownerRecord } from "./ownership.ts";
12
11
  import { RunPaneAllocator } from "./run-pane-allocator.ts";
13
- import { applyReport, applyTaskStatus, normalizeNeedsInputReport, projectTaskActivity, settleAttemptFailure, type PersistedReport, type RunRecord, type TaskActivity, type TaskRecord, type TaskStatus } from "./records.ts";
12
+ import { applyReport, applyTaskStatus, isActiveStatus, projectTaskActivity, readReport, readTaskRecord, settleAttemptFailure, type PersistedDocument, type PersistedReport, type RunRecord, type RunStatus, type TaskActivity, type TaskRecord, type TaskStatus } from "./records.ts";
14
13
  import { reportProtocolPrompt } from "./protocol.ts";
15
14
  import { cleanupRunTab } from "./tab-cleanup.ts";
16
15
  import { aggregateTaskStatus, applyRunStatus } from "./run-status.ts";
16
+ import { acquireDirectoryLock, adoptRecord, queueLockPath, withRunStateLock, type DirectoryLock } from "./run-state.ts";
17
+ import { aggregateTasksWithResume, readTaskEntries } from "./resume-state.ts";
17
18
  import { isAbortError } from "./errors.ts";
18
19
  import { attemptReportPath } from "./attempts.ts";
19
20
 
@@ -49,11 +50,9 @@ export interface BatchTaskResult {
49
50
  name: string;
50
51
  status: Exclude<BatchTaskStatus, "queued" | "running">;
51
52
  summary: string;
52
- documents: HerdrSubagentsDocument[];
53
+ documents: PersistedDocument[];
53
54
  error?: string;
54
55
  question?: string;
55
- failureKind?: "missing_input";
56
- requiredInput?: string;
57
56
  paneId: string;
58
57
  recordDirectory: string;
59
58
  sessionFile: string;
@@ -68,7 +67,7 @@ export interface BatchResult {
68
67
  tasks: BatchTaskResult[];
69
68
  activity: TaskActivity[];
70
69
  summary: string;
71
- documents: HerdrSubagentsDocument[];
70
+ documents: PersistedDocument[];
72
71
  recordDirectory: string;
73
72
  tabId?: string;
74
73
  question?: string;
@@ -132,11 +131,33 @@ export async function runHerdrSubagentsBatch(tasks: BatchTask[], options: BatchO
132
131
  tabId = paneAllocator.tabId;
133
132
  return paneId;
134
133
  };
134
+ // Settling the run must not be cancellable, so the terminal status is never written under options.signal.
135
+ let settledAggregate: RunStatus | undefined;
136
+ const persistRunStatus = async (status: BatchResult["status"], lifecycle: { question?: string; error?: string }, force = false) => {
137
+ settledAggregate = await withRunStateLock(runFile, undefined, async (run) => {
138
+ let effective: RunStatus = status;
139
+ try {
140
+ // Task records are more current than this batch's own view once a follow-up continues a
141
+ // settled task. A thrown batch still forces its outcome, but never over live work.
142
+ const observed = aggregateTasksWithResume((await readTaskEntries(runDirectory)).map((entry) => entry.task), run.resume?.active);
143
+ if (!force || isActiveStatus(observed)) effective = observed;
144
+ } catch (error) {
145
+ options.onCleanupError?.(`Could not inspect task records while settling the run; used this batch's own view: ${error instanceof Error ? error.message : String(error)}`);
146
+ }
147
+ applyRunStatus(run, effective, lifecycle);
148
+ adoptRecord(runRecord, run);
149
+ return effective;
150
+ });
151
+ };
135
152
 
136
153
  emit("starting", `Starting up to ${effectiveConcurrency} interactive Pi Agent${effectiveConcurrency === 1 ? "" : "s"}...`);
137
154
  let nextIndex = 0;
138
155
  let settled = 0;
156
+ let queueLock: DirectoryLock | undefined;
139
157
  try {
158
+ // One batch lane blocking does not stop the others, so this batch keeps starting queued tasks.
159
+ // Holding the queue lock for its whole life keeps a concurrent response from starting them too.
160
+ queueLock = await acquireDirectoryLock(queueLockPath(runDirectory), { wait: false });
140
161
  const runOne = async (index: number): Promise<void> => {
141
162
  const task = tasks[index];
142
163
  const context = contexts[index];
@@ -153,11 +174,14 @@ export async function runHerdrSubagentsBatch(tasks: BatchTask[], options: BatchO
153
174
  await writeJsonAtomic(context.taskFile, context.taskRecord);
154
175
  activity[index] = projectTaskActivity(context.taskRecord, index);
155
176
  emit("running", `${settled}/${tasks.length} tasks settled.`);
156
- const prompted = await promptLiveAgent({ herdr, task: { ...task, id: context.taskId, sessionFile: context.sessionFile }, paneId, prompt: task.prompt, systemPromptFile: context.systemPromptFile, agentName, signal: options.signal, stalledWarningMs: options.stalledWarningSeconds ? options.stalledWarningSeconds * 1000 : undefined, onStalled: () => emit("running", `${task.name} appears stalled; it is still running.`) });
177
+ const prompted = await promptLiveAgent({ herdr, task: { ...task, id: context.taskId, sessionFile: context.sessionFile }, paneId, prompt: task.prompt, systemPromptFile: context.systemPromptFile, agentName, signal: options.signal, stalledWarningMs: options.stalledWarningSeconds ? options.stalledWarningSeconds * 1000 : undefined, onStalled: () => emit("running", `${task.name} appears stalled; it is still running.`), onRuntimeUpdate: (updated) => {
178
+ Object.assign(context.taskRecord, updated);
179
+ activity[index] = projectTaskActivity(context.taskRecord, index);
180
+ emit("running", `${settled}/${tasks.length} tasks settled.`);
181
+ } });
157
182
  agentName = prompted.agentName;
158
183
  context.taskRecord.currentAttemptId = prompted.attemptId;
159
- report = normalizeNeedsInputReport(prompted.report, effectiveConcurrency);
160
- if (report !== prompted.report) await Promise.all([writeJsonAtomic(context.reportFile, report), writeJsonAtomic(attemptReportPath(context.taskDirectory, prompted.attemptId), report)]);
184
+ report = prompted.report;
161
185
  status = applyReport(context.taskRecord, report);
162
186
  } catch (error) {
163
187
  const settled = await settleAttemptFailure(context.taskRecord, context.reportFile, {
@@ -170,7 +194,7 @@ export async function runHerdrSubagentsBatch(tasks: BatchTask[], options: BatchO
170
194
  }
171
195
  Object.assign(context.taskRecord, { paneId, agentName });
172
196
  await writeJsonAtomic(context.taskFile, context.taskRecord);
173
- results[index] = { index, name: task.name, status, summary: report.summary, documents: report.documents, ...(report.error ? { error: report.error } : {}), ...(report.question ? { question: report.question } : {}), ...(report.failureKind ? { failureKind: report.failureKind } : {}), ...(report.requiredInput ? { requiredInput: report.requiredInput } : {}), paneId, recordDirectory: context.taskDirectory, sessionFile: context.sessionFile };
197
+ results[index] = { index, name: task.name, status, summary: report.summary, documents: report.documents, ...(report.error ? { error: report.error } : {}), ...(report.question ? { question: report.question } : {}), paneId, recordDirectory: context.taskDirectory, sessionFile: context.sessionFile };
174
198
  activity[index] = projectTaskActivity(context.taskRecord, index);
175
199
  settled += 1;
176
200
  emit("running", `${settled}/${tasks.length} tasks settled.`);
@@ -194,6 +218,24 @@ export async function runHerdrSubagentsBatch(tasks: BatchTask[], options: BatchO
194
218
  await writeJsonAtomic(contexts[index].taskFile, contexts[index].taskRecord);
195
219
  }
196
220
  }
221
+ // A response can settle a lane this batch left blocked while its independent siblings were still
222
+ // running, so adopt the task records before reporting an outcome this batch no longer owns.
223
+ for (let index = 0; index < results.length; index += 1) {
224
+ if (results[index]?.status !== "blocked") continue;
225
+ let latest: TaskRecord;
226
+ let report: PersistedReport;
227
+ try { [latest, report] = await Promise.all([readTaskRecord(contexts[index].taskFile), readReport(contexts[index].reportFile)]); }
228
+ catch { continue; }
229
+ if (latest.status === "blocked" || isActiveStatus(latest.status)) continue;
230
+ adoptRecord(contexts[index].taskRecord, latest);
231
+ activity[index] = projectTaskActivity(contexts[index].taskRecord, index);
232
+ const previous = results[index];
233
+ results[index] = {
234
+ index: previous.index, name: previous.name, status: latest.status as BatchTaskResult["status"],
235
+ summary: report.summary, documents: report.documents ?? [], ...(report.error ? { error: report.error } : {}),
236
+ paneId: previous.paneId, recordDirectory: previous.recordDirectory, sessionFile: previous.sessionFile,
237
+ };
238
+ }
197
239
  const settledResults = results.filter(Boolean);
198
240
  const blocked = settledResults.find((result) => result.status === "blocked");
199
241
  const failed = settledResults.filter((result) => result.status === "failed").length;
@@ -201,15 +243,14 @@ export async function runHerdrSubagentsBatch(tasks: BatchTask[], options: BatchO
201
243
  const cancelled = settledResults.filter((result) => result.status === "cancelled").length;
202
244
  const status = aggregateTaskStatus(activity.map((entry) => entry.status)) as BatchResult["status"];
203
245
  const summary = status === "cancelled" ? "Subagent batch cancelled." : blocked ? `${blocked.name} needs input: ${blocked.question}` : status === "partial" ? `${completed}/${tasks.length} tasks completed; ${failed} failed; ${cancelled} cancelled.` : failed ? `${completed}/${tasks.length} tasks completed; ${failed} failed.` : `${tasks.length}/${tasks.length} tasks completed.`;
204
- applyRunStatus(runRecord, status, { question: blocked?.question });
205
- await writeJsonAtomic(runFile, runRecord);
246
+ await persistRunStatus(status, { question: blocked?.question });
206
247
  return { ok: status === "completed", status, runId, label: options.label, requestedConcurrency, effectiveConcurrency, tasks: settledResults, activity, summary, documents: settledResults.flatMap((result) => result.documents), recordDirectory: runDirectory, tabId, ...(blocked?.question ? { question: blocked.question, allowedActions: ["respond", "cancel"] } : {}) };
207
248
  } catch (error) {
208
- applyRunStatus(runRecord, options.signal?.aborted ? "cancelled" : "failed", { error: error instanceof Error ? error.message : String(error) });
209
- await writeJsonAtomic(runFile, runRecord);
249
+ await persistRunStatus(options.signal?.aborted ? "cancelled" : "failed", { error: error instanceof Error ? error.message : String(error) }, true);
210
250
  throw error;
211
251
  } finally {
212
- const keepTab = !options.signal?.aborted && activity.some((entry) => entry.status === "blocked" || entry.status === "queued" || entry.status === "running");
252
+ await queueLock?.release();
253
+ const keepTab = !options.signal?.aborted && (activity.some((entry) => entry.status === "blocked" || entry.status === "queued" || entry.status === "running") || (settledAggregate !== undefined && isActiveStatus(settledAggregate)));
213
254
  if (tabId && !keepTab) {
214
255
  await cleanupRunTab({
215
256
  herdr, tabId, runFile, runRecord, signal: options.signal,