@maxiaochao/pi-toolkit 0.10.21 → 0.10.23
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 +10 -0
- package/package.json +1 -1
- package/packages/herdr-subagents/CHANGELOG.md +22 -0
- package/packages/herdr-subagents/README.md +16 -8
- package/packages/herdr-subagents/RESUME-LIFECYCLE-DESIGN.md +84 -0
- package/packages/herdr-subagents/TESTING.md +13 -3
- package/packages/herdr-subagents/extensions/herdr-subagents.ts +4 -17
- package/packages/herdr-subagents/extensions/subagent-report.ts +2 -2
- package/packages/herdr-subagents/package.json +1 -1
- package/packages/herdr-subagents/src/batch-runner.ts +56 -15
- package/packages/herdr-subagents/src/blocking.ts +115 -72
- package/packages/herdr-subagents/src/continue-run.ts +22 -7
- package/packages/herdr-subagents/src/fleet.ts +2 -1
- package/packages/herdr-subagents/src/herdr.ts +10 -1
- package/packages/herdr-subagents/src/historical-resume.ts +184 -0
- package/packages/herdr-subagents/src/history.ts +5 -131
- package/packages/herdr-subagents/src/live-agent.ts +99 -35
- package/packages/herdr-subagents/src/parameters.ts +2 -2
- package/packages/herdr-subagents/src/protocol.ts +3 -1
- package/packages/herdr-subagents/src/reconcile.ts +156 -135
- package/packages/herdr-subagents/src/records.ts +50 -32
- package/packages/herdr-subagents/src/resume-state.ts +46 -0
- package/packages/herdr-subagents/src/run-pane-allocator.ts +39 -37
- package/packages/herdr-subagents/src/run-state.ts +120 -0
- package/packages/herdr-subagents/src/status.ts +0 -6
- package/packages/herdr-subagents/src/tab-cleanup.ts +16 -16
- package/packages/herdr-subagents/src/tool.ts +1 -54
- package/packages/herdr-subagents/src/validation.ts +1 -1
- package/packages/herdr-subagents/tests/extension-contract.ts +4 -4
- package/packages/herdr-subagents/tests/run-tests.mjs +579 -60
- package/packages/herdr-subagents/src/runner.ts +0 -234
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
## 2026-10-12 - v0.10.23
|
|
2
|
+
|
|
3
|
+
- bundle herdr-subagents v0.4.9: Stabilize concurrency checks on slower CI runners.
|
|
4
|
+
## 2026-10-12 - v0.10.22
|
|
5
|
+
|
|
6
|
+
- 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
|
|
7
|
+
- bundle Herdr Subagents runtime telemetry and concurrent historical-resume lifecycle fixes without conflating Herdr UI blocking with business blocking
|
|
8
|
+
- 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
|
|
9
|
+
- 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
|
|
10
|
+
|
|
1
11
|
## 2026-10-10 - v0.10.21
|
|
2
12
|
|
|
3
13
|
- 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.
|
|
3
|
+
"version": "0.10.23",
|
|
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,25 @@
|
|
|
1
|
+
## 2026-10-12 - v0.4.9
|
|
2
|
+
|
|
3
|
+
- Stabilize concurrency checks on slower CI runners.
|
|
4
|
+
## 2026-10-12 - v0.4.8
|
|
5
|
+
|
|
6
|
+
- make blocking behave identically in serial and parallel runs and let respond continue a named failed task from its saved session
|
|
7
|
+
- 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
|
|
8
|
+
- allow different historical tasks in one run to resume concurrently in a shared tab while fencing task writers, aggregate state, and final cleanup
|
|
9
|
+
- reserve `retrying` display support without fabricating retries from ambiguous Pi lifecycle events
|
|
10
|
+
- 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
|
|
11
|
+
- 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
|
|
12
|
+
- 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
|
|
13
|
+
- 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
|
|
14
|
+
- claim a continued task before any pane work so a sibling that settles first cannot close the tab the continuation is about to use
|
|
15
|
+
- report a still-queued sibling as running work instead of inventing an input request with no question
|
|
16
|
+
- require the task number on every `respond` so an answer never selects a task implicitly
|
|
17
|
+
- rename the child report status `needs-input` to `blocked` and reduce the child contract to `completed`, `blocked`, and `failed`
|
|
18
|
+
- let a task block at any concurrency instead of downgrading concurrent input requests to `missing_input` failures, and drop `failureKind`/`requiredInput`
|
|
19
|
+
- 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
|
|
20
|
+
- 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
|
|
21
|
+
- settle a continued run's status through the run lock instead of overwriting it from a stale copy
|
|
22
|
+
|
|
1
23
|
## 2026-10-10 - v0.4.7
|
|
2
24
|
|
|
3
25
|
- 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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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.
|
|
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", "
|
|
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
|
|
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 {
|
|
@@ -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,
|
|
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:
|
|
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:
|
|
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 =
|
|
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 } : {}),
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|