@gotgenes/pi-subagents 16.1.0 → 16.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/dist/public.d.ts +19 -22
- package/docs/architecture/architecture.md +44 -12
- package/docs/plans/0373-extract-subagent-state.md +250 -0
- package/docs/plans/0403-abort-subagents-on-interrupt.md +180 -0
- package/docs/retro/0373-extract-subagent-state.md +94 -0
- package/docs/retro/0381-replace-concurrency-queue-with-limiter.md +46 -0
- package/docs/retro/0403-abort-subagents-on-interrupt.md +49 -0
- package/package.json +1 -1
- package/src/handlers/index.ts +1 -0
- package/src/handlers/interrupt.ts +49 -0
- package/src/index.ts +5 -1
- package/src/lifecycle/subagent-manager.ts +19 -16
- package/src/lifecycle/subagent-state.ts +156 -0
- package/src/lifecycle/subagent.ts +84 -162
- package/src/observation/record-observer.ts +15 -13
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [16.1.1](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v16.1.0...pi-subagents-v16.1.1) (2026-06-14)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* abort all subagents on parent interrupt ([#403](https://github.com/gotgenes/pi-packages/issues/403)) ([0c951d3](https://github.com/gotgenes/pi-packages/commit/0c951d3da27123a61c95a7f9a07ddb4cf5ed7e89))
|
|
14
|
+
|
|
8
15
|
## [16.1.0](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v16.0.0...pi-subagents-v16.1.0) (2026-06-14)
|
|
9
16
|
|
|
10
17
|
|
package/dist/public.d.ts
CHANGED
|
@@ -29,6 +29,25 @@ type LifetimeUsage = {
|
|
|
29
29
|
cacheWrite: number;
|
|
30
30
|
};
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* subagent-state.ts — SubagentState value object: lifecycle status and metrics.
|
|
34
|
+
*
|
|
35
|
+
* Owns the passive, readable state of a subagent — status, result, error,
|
|
36
|
+
* timestamps, and stats (toolUses, lifetimeUsage, compactionCount) — together
|
|
37
|
+
* with the transition methods (markRunning, markCompleted, …) and accumulation
|
|
38
|
+
* methods (incrementToolUses, addUsage, incrementCompactions) that mutate it.
|
|
39
|
+
*
|
|
40
|
+
* State is encapsulated behind getters; external code reads through them but
|
|
41
|
+
* mutates only via the transition/accumulation methods. The value object owns
|
|
42
|
+
* all of its own mutations — no field is written from outside.
|
|
43
|
+
*
|
|
44
|
+
* Subagent holds one of these privately and delegates its getters and mutation
|
|
45
|
+
* methods to it. Extracting it lets the lifecycle state machine and the
|
|
46
|
+
* session-event observer be unit-tested without constructing an executor.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
type SubagentStatus = "queued" | "running" | "completed" | "steered" | "aborted" | "stopped" | "error";
|
|
50
|
+
|
|
32
51
|
/**
|
|
33
52
|
* workspace.ts — The single generative extension seam (ADR 0002, Phase 16 Step 2).
|
|
34
53
|
*
|
|
@@ -70,28 +89,6 @@ interface WorkspaceProvider {
|
|
|
70
89
|
prepare(ctx: WorkspacePrepareContext): Promise<Workspace | undefined>;
|
|
71
90
|
}
|
|
72
91
|
|
|
73
|
-
/**
|
|
74
|
-
* subagent.ts — Subagent class with encapsulated status-transition logic and per-subagent behavior.
|
|
75
|
-
*
|
|
76
|
-
* Status transitions (status, result, error, startedAt, completedAt) are owned
|
|
77
|
-
* by the class and exposed via transition methods. External code reads these
|
|
78
|
-
* fields through public properties but cannot write them directly.
|
|
79
|
-
*
|
|
80
|
-
* Stats (toolUses, lifetimeUsage, compactionCount) are owned by the class and
|
|
81
|
-
* accumulated via mutation methods (incrementToolUses, addUsage, incrementCompactions).
|
|
82
|
-
*
|
|
83
|
-
* Behavior (abort, steer buffering) lives on the subagent rather than on
|
|
84
|
-
* SubagentManager — each subagent manages its own lifecycle concerns.
|
|
85
|
-
*
|
|
86
|
-
* The child's working directory is supplied by a registered WorkspaceProvider
|
|
87
|
-
* (the workspace seam); with no provider the child runs in the parent cwd.
|
|
88
|
-
*
|
|
89
|
-
* Phase-specific collaborators (subagentSession, notification) are attached
|
|
90
|
-
* after construction as lifecycle information becomes available.
|
|
91
|
-
*/
|
|
92
|
-
|
|
93
|
-
type SubagentStatus = "queued" | "running" | "completed" | "steered" | "aborted" | "stopped" | "error";
|
|
94
|
-
|
|
95
92
|
/**
|
|
96
93
|
* service.ts — Public API surface for cross-extension access to subagents.
|
|
97
94
|
*
|
|
@@ -107,6 +107,8 @@ classDiagram
|
|
|
107
107
|
+id: string
|
|
108
108
|
+type: SubagentType
|
|
109
109
|
+description: string
|
|
110
|
+
-state: SubagentState
|
|
111
|
+
-execution: SubagentExecution
|
|
110
112
|
+status: SubagentStatus
|
|
111
113
|
+result?: string
|
|
112
114
|
+error?: string
|
|
@@ -114,13 +116,8 @@ classDiagram
|
|
|
114
116
|
+lifetimeUsage: LifetimeUsage
|
|
115
117
|
+subagentSession?: SubagentSession
|
|
116
118
|
+notification?: NotificationState
|
|
117
|
-
+markRunning()
|
|
118
|
-
+markCompleted()
|
|
119
|
-
+markAborted()
|
|
120
|
-
+markSteered()
|
|
121
|
-
+markError()
|
|
122
|
-
+markStopped()
|
|
123
|
-
+resetForResume()
|
|
119
|
+
+markRunning() delegates
|
|
120
|
+
+markCompleted() delegates
|
|
124
121
|
+run()
|
|
125
122
|
+resume(prompt, signal)
|
|
126
123
|
+abort(): boolean
|
|
@@ -138,6 +135,34 @@ classDiagram
|
|
|
138
135
|
+releaseListeners()
|
|
139
136
|
}
|
|
140
137
|
|
|
138
|
+
class SubagentState {
|
|
139
|
+
+status: SubagentStatus
|
|
140
|
+
+result?: string
|
|
141
|
+
+error?: string
|
|
142
|
+
+startedAt: number
|
|
143
|
+
+completedAt?: number
|
|
144
|
+
+toolUses: number
|
|
145
|
+
+lifetimeUsage: LifetimeUsage
|
|
146
|
+
+compactionCount: number
|
|
147
|
+
+markRunning() ... markStopped()
|
|
148
|
+
+resetForResume()
|
|
149
|
+
+incrementToolUses()
|
|
150
|
+
+addUsage(delta)
|
|
151
|
+
+incrementCompactions()
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
class SubagentExecution {
|
|
155
|
+
+createSubagentSession(params)
|
|
156
|
+
+snapshot: ParentSnapshot
|
|
157
|
+
+prompt: string
|
|
158
|
+
+baseCwd: string
|
|
159
|
+
+observer?: SubagentLifecycleObserver
|
|
160
|
+
+getRunConfig?()
|
|
161
|
+
+getWorkspaceProvider?()
|
|
162
|
+
+model?, maxTurns?, thinkingLevel?
|
|
163
|
+
+parentSession?, signal?
|
|
164
|
+
}
|
|
165
|
+
|
|
141
166
|
class SubagentManager {
|
|
142
167
|
+spawn(snapshot, type, prompt, config)
|
|
143
168
|
+spawnAndWait(snapshot, type, prompt, config)
|
|
@@ -173,6 +198,8 @@ classDiagram
|
|
|
173
198
|
}
|
|
174
199
|
|
|
175
200
|
SubagentManager --> Subagent : creates/manages
|
|
201
|
+
Subagent --> SubagentState : owns (private)
|
|
202
|
+
Subagent --> SubagentExecution : runs via (mandatory)
|
|
176
203
|
SubagentManager --> ParentSnapshot : receives at spawn
|
|
177
204
|
SubagentsService --> SubagentManager : wraps via adapter
|
|
178
205
|
SubagentManager --> AgentTypeRegistry : resolves types
|
|
@@ -247,7 +274,7 @@ sequenceDiagram
|
|
|
247
274
|
|
|
248
275
|
## Module organization
|
|
249
276
|
|
|
250
|
-
The extension has
|
|
277
|
+
The extension has 58 source files organized into six domains plus entry-point wiring.
|
|
251
278
|
All eight domains have directories: `config/`, `session/`, `lifecycle/`, `observation/`, `service/`, `tools/`, `ui/`, and `handlers/`.
|
|
252
279
|
Issue #164 moved the 26 previously flat root-level files into five new domain directories, reducing the root to 5 files + 8 directories.
|
|
253
280
|
|
|
@@ -283,6 +310,7 @@ src/
|
|
|
283
310
|
│ ├── subagent-session.ts born-complete child session: turn loop, steer, dispose
|
|
284
311
|
│ ├── turn-limits.ts normalizeMaxTurns (turn-count policy)
|
|
285
312
|
│ ├── subagent.ts owns full execution lifecycle (run, abort, steer, workspace)
|
|
313
|
+
│ ├── subagent-state.ts lifecycle status + metrics value object (transitions, accumulators)
|
|
286
314
|
│ ├── concurrency-limiter.ts background admission gate: schedules run thunks FIFO against the limit
|
|
287
315
|
│ ├── parent-snapshot.ts immutable spawn-time parent state
|
|
288
316
|
│ ├── child-lifecycle.ts child-execution lifecycle event publisher
|
|
@@ -325,6 +353,7 @@ src/
|
|
|
325
353
|
│
|
|
326
354
|
└── handlers/ event handlers
|
|
327
355
|
├── index.ts barrel re-export
|
|
356
|
+
├── interrupt.ts turn_start handler — abort all subagents on parent interrupt (ESC)
|
|
328
357
|
├── lifecycle.ts session_start, session_before_switch, session_shutdown
|
|
329
358
|
└── tool-start.ts tool_execution_start handler
|
|
330
359
|
```
|
|
@@ -646,7 +675,8 @@ Bags with 10+ fields are the highest priority for decomposition.
|
|
|
646
675
|
| `AgentToolDeps` | 8 | agent-tool | ✓ done |
|
|
647
676
|
| `AgentMenuDeps` | 8 | agent-menu | ✓ done |
|
|
648
677
|
| `ConversationViewerOptions` | 8 | conversation-viewer | Low |
|
|
649
|
-
| `SubagentInit` |
|
|
678
|
+
| `SubagentInit` | 5 (id, type, description, invocation, execution, state) | subagent (one production site) | ✓ done |
|
|
679
|
+
| `SubagentExecution` | 12 (4 mandatory: factory, snapshot, prompt, baseCwd) | subagent (mandatory collaborator) | ✓ done |
|
|
650
680
|
|
|
651
681
|
### Complexity hotspots
|
|
652
682
|
|
|
@@ -864,7 +894,7 @@ Updated health metrics (fallow, package-wide including tests):
|
|
|
864
894
|
| Metric | Phase 16 baseline | Current |
|
|
865
895
|
| -------------------------- | ------------------------------ | --------------------------------------------- |
|
|
866
896
|
| Health score | 78/100 (B) | 78/100 (B) |
|
|
867
|
-
| Source LOC | 7,778 (57 files) | ~7,400 (
|
|
897
|
+
| Source LOC | 7,778 (57 files) | ~7,400 (57 files) |
|
|
868
898
|
| Dead code | 0 files, 0 exports | 0 files, 0 exports |
|
|
869
899
|
| Maintainability index | 90.8 (good) | 90.8 (good) |
|
|
870
900
|
| Avg / P90 cyclomatic | 1.4 / 2 | 1.4 / 2 |
|
|
@@ -914,7 +944,7 @@ Priority = Impact × (6 − Risk).
|
|
|
914
944
|
The settings `onMaxConcurrentChanged` hook wires to `limiter.recheck()` in `index.ts`; `dispose()` calls `limiter.clear()` to drop pending thunks.
|
|
915
945
|
- Outcome: dependency direction is strictly manager → limiter (no callback back-edge; the `prefer-const` eslint-disable in the test helper is deleted); the observer's two queue relays are gone; every spawned agent has a `promise` at spawn, collapsing `waitForAll`'s `while (true)` drain loop and its eslint-disable.
|
|
916
946
|
|
|
917
|
-
#### Step 2 — Extract `SubagentState`; make `Subagent` execution deps mandatory ([#373])
|
|
947
|
+
#### Step 2 — Extract `SubagentState`; make `Subagent` execution deps mandatory ([#373]) ✅ Complete
|
|
918
948
|
|
|
919
949
|
- Targets: `src/lifecycle/subagent.ts` (state fields, transition/accumulation methods, constructor, `run()` guards), `src/lifecycle/subagent-manager.ts` (`spawn`), `test/helpers/make-subagent.ts`, `test/lifecycle/subagent.test.ts`, `test/observation/record-observer.test.ts`.
|
|
920
950
|
- Smell: Category B (god interface — ~20 fields) and Category D (constructibility: "optional for tests" fields with compensating runtime throws).
|
|
@@ -926,7 +956,9 @@ Priority = Impact × (6 − Risk).
|
|
|
926
956
|
- Outcome: state-machine and observer tests target `SubagentState` directly (no stub execution); `Subagent` is construct-complete with no optional execution fields and no runtime throws (grep-verifiable: no "not configured for execution" in `subagent.ts`); the record-vs-executor duality is resolved, not type-encoded.
|
|
927
957
|
- Scope boundary: stats stay on `SubagentState` for now.
|
|
928
958
|
Hoisting **metrics** into a projection over the child session's event stream and extracting **result delivery** (`notification`/`resultConsumed`) into its own domain are the remaining two of the four domains, deferred to a later phase per the refinement.
|
|
929
|
-
-
|
|
959
|
+
- Landed: `SubagentState` (`src/lifecycle/subagent-state.ts`) owns status/result/error/timestamps/stats and the transition/accumulation methods; `Subagent` delegates getters and `markX`/`incrementX`/`addUsage` to it.
|
|
960
|
+
`subscribeSubagentObserver` targets `SubagentState`, so observer and state-machine tests no longer stub execution.
|
|
961
|
+
`SubagentExecution` is a mandatory constructor collaborator (production wires it in the single `spawn()` site; passive records build via `make-subagent.ts`), and the two `run()` throws are gone.
|
|
930
962
|
|
|
931
963
|
#### Step 3 — Encapsulate run start and notification attachment on Subagent ([#374])
|
|
932
964
|
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
---
|
|
2
|
+
issue: 373
|
|
3
|
+
issue_title: "Extract SubagentState; make Subagent execution deps mandatory"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Extract `SubagentState`; make `Subagent` execution deps mandatory
|
|
7
|
+
|
|
8
|
+
## Problem Statement
|
|
9
|
+
|
|
10
|
+
`Subagent` is simultaneously a passive record and an executor.
|
|
11
|
+
Its `SubagentInit` carries ~20 fields — nearly all optional with "required for `run()`, optional for tests" semantics — and `run()` compensates with two runtime guards that throw `"Subagent not configured for execution"`.
|
|
12
|
+
This violates the construct-complete principle: one class is both a display-only snapshot (tests and the UI read `.status`/`.result`/stats) and an executor (production wires the session factory, observer, run config, and workspace provider).
|
|
13
|
+
|
|
14
|
+
The earlier framing — split `SubagentInit` into a present-or-absent `SubagentRunSpec` + `SubagentExecutionDeps` pair — only *type-encodes* the duality; it does not remove it.
|
|
15
|
+
The stronger move (captured in the architecture doc's "First-principles refinement") observes that the passive-record need is *test-only*: production constructs a `Subagent` in exactly one place (`SubagentManager.spawn`), always fully wired.
|
|
16
|
+
So the readable state is the common denominator (extract it as a value object), and the execution deps are the optional-only-for-tests part (make them mandatory; push the passive case into the test factory).
|
|
17
|
+
|
|
18
|
+
## Goals
|
|
19
|
+
|
|
20
|
+
- Extract `SubagentState` — a value object owning status, result, error, timestamps, and stats (`toolUses`, `lifetimeUsage`, `compactionCount`) plus their transition (`markRunning`, `markCompleted`, …) and accumulation (`incrementToolUses`, `addUsage`, `incrementCompactions`) methods.
|
|
21
|
+
- `Subagent` holds one `SubagentState` privately; its existing getters and `markX`/`incrementX`/`addUsage` methods become one-line delegations, leaving the ~40 read sites and the external mutation callers (`markStopped` in the manager, `markCompleted` in `get-result-tool.test.ts`) unchanged.
|
|
22
|
+
- Collapse the ~12 remaining execution inputs into a single **mandatory** `SubagentExecution` collaborator; `SubagentManager.spawn` always supplies it.
|
|
23
|
+
- Delete the two `"not configured for execution"` throws in `run()` — impossible by construction.
|
|
24
|
+
- Move the passive-record construction entirely into `test/helpers/make-subagent.ts`.
|
|
25
|
+
- Retarget `subscribeSubagentObserver` at `SubagentState` so state-machine and observer tests need no stub execution.
|
|
26
|
+
|
|
27
|
+
This change is **not breaking** for the published service surface.
|
|
28
|
+
`src/service/service.ts` exposes `SubagentRecord`, `SubagentStatus`, and the spawn-config types — not `SubagentInit` or the `Subagent` constructor.
|
|
29
|
+
The read API (getters, `SubagentStatus`) is unchanged; only the internal constructor signature changes.
|
|
30
|
+
|
|
31
|
+
## Non-Goals
|
|
32
|
+
|
|
33
|
+
- Hoisting **metrics** (tool uses, token usage, compaction count) into a projection over the child session's event stream — stats stay on `SubagentState` for now (the third of four domains in the refinement, deferred).
|
|
34
|
+
- Extracting **result delivery** (`notification` / `resultConsumed`) into its own domain — the fourth domain, deferred.
|
|
35
|
+
- Encapsulating run start / making `promise` and `notification` read-only — that is Step 3 (#374); `notification` stays created in the constructor here.
|
|
36
|
+
- Extracting run-listener / workspace-bracket collaborators — Step 4 (#375).
|
|
37
|
+
- Renaming `record-observer.ts` — only its parameter type changes.
|
|
38
|
+
|
|
39
|
+
## Background
|
|
40
|
+
|
|
41
|
+
- `src/lifecycle/subagent.ts` — the class under change.
|
|
42
|
+
State fields (`_status`, `_result`, `_error`, `_startedAt`, `_completedAt`, `_toolUses`, `_lifetimeUsage`, `_compactionCount`) sit behind getters; transition/accumulation methods mutate them.
|
|
43
|
+
`run()` reads the execution inputs and throws if `createSubagentSession`/`snapshot`/`prompt` are missing.
|
|
44
|
+
`resume()` reads `observer` and throws only on a missing session (a genuine runtime condition — kept).
|
|
45
|
+
- `src/lifecycle/subagent-manager.ts` — `spawn()` (line ~139) is the **only** production `new Subagent(...)` site; it sets the initial status (`queued` for background so the limiter thunk's `status !== "queued"` guard works, `running` for foreground).
|
|
46
|
+
- `src/observation/record-observer.ts` — `subscribeSubagentObserver(session, record, { onCompact })` calls `record.incrementToolUses()`, `record.addUsage(…)`, `record.incrementCompactions()`, and forwards `onCompact(record, info)`.
|
|
47
|
+
- `src/lifecycle/usage.ts` — `LifetimeUsage` type and the `addUsage(into, delta)` accumulator that `SubagentState` will own.
|
|
48
|
+
- `test/helpers/make-subagent.ts` — `createTestSubagent` builds passive records; stat shorthands apply via mutation methods.
|
|
49
|
+
- `test/lifecycle/subagent.test.ts` (~700 LOC) and `test/observation/record-observer.test.ts` — the test files that construct `Subagent` directly.
|
|
50
|
+
|
|
51
|
+
AGENTS.md / code-design constraints that apply:
|
|
52
|
+
|
|
53
|
+
- Keep Pi SDK imports out of `SubagentState` — it is a pure value object (imports only `LifetimeUsage`/`addUsage`).
|
|
54
|
+
- `Subagent` is exported from the `types.ts` barrel; verify the barrel re-export still resolves after the file split.
|
|
55
|
+
- `SubagentStatus` is re-exported by `src/service/service.ts` from `#src/lifecycle/subagent` — keep that import path valid (re-export `SubagentStatus` from `subagent.ts` even if its definition moves to `subagent-state.ts`), so the public type bundle path is unchanged.
|
|
56
|
+
|
|
57
|
+
## Design Overview
|
|
58
|
+
|
|
59
|
+
### `SubagentState` value object (`src/lifecycle/subagent-state.ts`)
|
|
60
|
+
|
|
61
|
+
A pure, independently constructible value object. `SubagentStatus` moves here (its natural home) and is re-exported from `subagent.ts`.
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
export type SubagentStatus =
|
|
65
|
+
| "queued" | "running" | "completed" | "steered"
|
|
66
|
+
| "aborted" | "stopped" | "error";
|
|
67
|
+
|
|
68
|
+
export interface SubagentStateInit {
|
|
69
|
+
status?: SubagentStatus;
|
|
70
|
+
result?: string;
|
|
71
|
+
error?: string;
|
|
72
|
+
startedAt?: number;
|
|
73
|
+
completedAt?: number;
|
|
74
|
+
// stats always start at zero; callers accumulate via mutation methods
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export class SubagentState {
|
|
78
|
+
constructor(init?: SubagentStateInit); // status ?? "queued", startedAt ?? Date.now()
|
|
79
|
+
// getters: status, result, error, startedAt, completedAt,
|
|
80
|
+
// toolUses, lifetimeUsage (Readonly), compactionCount
|
|
81
|
+
// transitions: markRunning, markCompleted, markAborted, markSteered,
|
|
82
|
+
// markError, markStopped, resetForResume
|
|
83
|
+
// accumulators: incrementToolUses, addUsage, incrementCompactions
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
It owns its mutations (no output arguments — methods write only its own private fields) and carries no upstream dependency beyond `usage.ts`.
|
|
88
|
+
|
|
89
|
+
### `SubagentExecution` collaborator (in `src/lifecycle/subagent.ts`)
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
export interface SubagentExecution {
|
|
93
|
+
createSubagentSession: (params: CreateSubagentSessionParams) => Promise<SubagentSession>;
|
|
94
|
+
snapshot: ParentSnapshot;
|
|
95
|
+
prompt: string;
|
|
96
|
+
baseCwd: string;
|
|
97
|
+
observer?: SubagentLifecycleObserver;
|
|
98
|
+
getRunConfig?: () => RunConfig;
|
|
99
|
+
getWorkspaceProvider?: () => WorkspaceProvider | undefined;
|
|
100
|
+
model?: Model<any>;
|
|
101
|
+
maxTurns?: number;
|
|
102
|
+
thinkingLevel?: ThinkingLevel;
|
|
103
|
+
parentSession?: ParentSessionInfo;
|
|
104
|
+
signal?: AbortSignal;
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
The four fields the old `run()` guards required (`createSubagentSession`, `snapshot`, `prompt`, plus `baseCwd`) are mandatory; the genuinely-optional behavior knobs stay optional.
|
|
109
|
+
|
|
110
|
+
### `Subagent` constructor
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
export interface SubagentInit {
|
|
114
|
+
id: string;
|
|
115
|
+
type: SubagentType;
|
|
116
|
+
description: string;
|
|
117
|
+
invocation?: AgentInvocation;
|
|
118
|
+
execution: SubagentExecution; // mandatory — no more "optional for tests"
|
|
119
|
+
state?: SubagentState; // defaults to new SubagentState() (fresh "queued")
|
|
120
|
+
}
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
`SubagentInit` drops from ~20 fields to 5.
|
|
124
|
+
`Subagent` keeps `id`/`type`/`description`/`invocation`, `abortController`, `subagentSession?`, `notification?`, steer buffer, and per-run listener handles; it holds `private readonly state` and `private readonly execution`.
|
|
125
|
+
Getters and mutation methods delegate one line to `this.state`.
|
|
126
|
+
`notification` is still created in the constructor from `execution.parentSession?.toolCallId` (Step 3 moves it).
|
|
127
|
+
|
|
128
|
+
### Consumer call site — `SubagentManager.spawn`
|
|
129
|
+
|
|
130
|
+
```ts
|
|
131
|
+
const execution: SubagentExecution = {
|
|
132
|
+
createSubagentSession: this.createSubagentSession,
|
|
133
|
+
snapshot, prompt, baseCwd: this.baseCwd,
|
|
134
|
+
observer: this.buildObserver(options),
|
|
135
|
+
getRunConfig: this.getRunConfig,
|
|
136
|
+
getWorkspaceProvider: () => this._workspaceProvider,
|
|
137
|
+
model: options.model, maxTurns: options.maxTurns,
|
|
138
|
+
thinkingLevel: options.thinkingLevel,
|
|
139
|
+
parentSession: options.parentSession, signal: options.signal,
|
|
140
|
+
};
|
|
141
|
+
const record = new Subagent({
|
|
142
|
+
id, type, description: options.description, invocation: options.invocation,
|
|
143
|
+
execution,
|
|
144
|
+
state: new SubagentState({ status: options.isBackground ? "queued" : "running", startedAt: Date.now() }),
|
|
145
|
+
});
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
Tell-Don't-Ask: the execution bundle is assembled once and handed over; nothing reaches back into `spawn`'s locals afterward.
|
|
149
|
+
|
|
150
|
+
### Observer retarget (`src/observation/record-observer.ts`)
|
|
151
|
+
|
|
152
|
+
The observer accumulates *stats*, not lifecycle — point it at `SubagentState` and drop the record from `onCompact`:
|
|
153
|
+
|
|
154
|
+
```ts
|
|
155
|
+
export function subscribeSubagentObserver(
|
|
156
|
+
session: SubscribableSession,
|
|
157
|
+
state: SubagentState,
|
|
158
|
+
options?: { onCompact?: (info: CompactionInfo) => void },
|
|
159
|
+
): () => void
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
`Subagent.run()`/`resume()` wire it with `this.state` and close over `this` to forward itself to the lifecycle observer:
|
|
163
|
+
|
|
164
|
+
```ts
|
|
165
|
+
this.attachObserver(subscribeSubagentObserver(this.subagentSession, this.state, {
|
|
166
|
+
onCompact: (info) => this.execution.observer?.onCompacted?.(this, info),
|
|
167
|
+
}));
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
This removes the observer's only dependency on `Subagent`, so observer tests construct a `SubagentState` directly.
|
|
171
|
+
`SubagentLifecycleObserver.onCompacted(agent, info)` is unchanged — it still receives the `Subagent`.
|
|
172
|
+
|
|
173
|
+
Decision: the observer takes `SubagentState` concretely rather than a one-off narrow `incrementToolUses`/`addUsage`/`incrementCompactions` interface.
|
|
174
|
+
`SubagentState` is a cohesive owned value object, not a wide dependency bag, and a single internal call site does not justify a speculative interface (recorded in Open Questions).
|
|
175
|
+
|
|
176
|
+
### Edge cases
|
|
177
|
+
|
|
178
|
+
- **Initial status for the limiter guard** — production passes `state: new SubagentState({ status: queued|running })`; the background path still observes `queued` so `limiter.schedule(() => record.status !== "queued" ? … : record.run())` behaves identically.
|
|
179
|
+
- **`resume()` without a prior run** — the `"not configured for resume — missing session"` throw stays; it guards a genuine runtime state (no session was ever created), not a construction concern.
|
|
180
|
+
- **`make-subagent.ts` passive records** — built as `state: new SubagentState({ status: "completed", result, startedAt, completedAt })` plus a `makeStubExecution()`; stat shorthands keep delegating through `Subagent`'s methods.
|
|
181
|
+
|
|
182
|
+
## Module-Level Changes
|
|
183
|
+
|
|
184
|
+
- **`src/lifecycle/subagent-state.ts`** (new) — `SubagentState`, `SubagentStateInit`, and the `SubagentStatus` type (moved from `subagent.ts`).
|
|
185
|
+
- **`src/lifecycle/subagent.ts`** —
|
|
186
|
+
- Re-export `SubagentStatus` from `subagent-state.ts` (keeps service.ts import path valid).
|
|
187
|
+
- Add `SubagentExecution`; rewrite `SubagentInit` to the 5-field shape; remove the flat execution/run-config fields.
|
|
188
|
+
- Remove the (unused) `isBackground?` field from `SubagentInit` — never read in this class.
|
|
189
|
+
- Hold `private readonly state` / `private readonly execution`; convert getters and `markX`/`incrementX`/`addUsage`/`resetForResume` to delegations.
|
|
190
|
+
- `run()`: delete the two `"not configured for execution"` throws; read inputs from `this.execution`; wire the observer at `this.state`.
|
|
191
|
+
- `resume()`: read `observer` from `this.execution`; keep the missing-session throw.
|
|
192
|
+
- **`src/lifecycle/subagent-manager.ts`** — `spawn()` builds the `SubagentExecution` bundle and the initial `SubagentState`; no other change (`markStopped` calls untouched).
|
|
193
|
+
- **`src/observation/record-observer.ts`** — param `record: Subagent` → `state: SubagentState`; `onCompact` signature `(record, info)` → `(info)`; update the JSDoc bullet wording.
|
|
194
|
+
- **`test/helpers/make-subagent.ts`** — add `makeStubExecution()`; build the passive record via `state` + `execution`.
|
|
195
|
+
- **`test/lifecycle/subagent.test.ts`** — funnel constructions through a local helper (prep refactor); move the pure state-machine `describe` blocks to the new state test; supply `execution` everywhere; update the missing-factory test (the throw is gone — replace with a type-level/construction assertion or remove).
|
|
196
|
+
- **`test/lifecycle/subagent-state.test.ts`** (new) — state-machine + accumulation tests targeting `SubagentState` directly.
|
|
197
|
+
- **`test/observation/record-observer.test.ts`** — `makeRecord` → build a `SubagentState`; assert stats on it; `onCompact` test uses `(info)`.
|
|
198
|
+
- **Docs** —
|
|
199
|
+
- `docs/architecture/architecture.md`: add `subagent-state.ts` to the lifecycle directory listing; update the `Subagent` class diagram (state delegations, new collaborators); mark **Step 2 ✅ Complete**; refresh the Phase 17 problem-statement prose (line ~879) and the type-complexity table (`SubagentInit` row at line ~649, add `SubagentExecution`).
|
|
200
|
+
- `.pi/skills/package-pi-subagents/SKILL.md`: Lifecycle domain `10 → 11` modules (add `subagent-state.ts`); total `56 → 57` files.
|
|
201
|
+
|
|
202
|
+
Grep sweep confirmed no other `src/`, `test/`, or `SKILL.md` references to the removed flat fields or the `"not configured for execution"` string outside `subagent.ts`.
|
|
203
|
+
|
|
204
|
+
## Test Impact Analysis
|
|
205
|
+
|
|
206
|
+
1. **New tests the extraction enables** — `test/lifecycle/subagent-state.test.ts` exercises every transition and accumulator on `SubagentState` with no `Subagent`, no execution stub, no session.
|
|
207
|
+
This is the construct-complete payoff: the state machine is now unit-testable in isolation.
|
|
208
|
+
2. **Tests that become redundant** — the state-machine `describe` blocks in `subagent.test.ts` (`markRunning`, `markCompleted`, `markAborted`, `markSteered`, `markError`, `markStopped`, `incrementToolUses`, `addUsage`, `incrementCompactions`, `resetForResume`, and the pure constructor-defaults cases) move out, leaving `subagent.test.ts` to cover identity, session-encapsulation delegation, abort, `run()`/`resume()`, `completeRun`/`failRun`, and listeners.
|
|
209
|
+
`record-observer.test.ts` no longer constructs a `Subagent` — it builds a `SubagentState`.
|
|
210
|
+
3. **Tests that must stay as-is** — `run()`/`resume()` integration tests genuinely exercise the executor (factory wiring, observer lifecycle, workspace bracket); the `createTestSubagent` consumers across `test/ui/` and `test/tools/` exercise read sites and are unaffected (the helper absorbs the construction change).
|
|
211
|
+
|
|
212
|
+
## TDD Order
|
|
213
|
+
|
|
214
|
+
1. **Extract `SubagentState` (pure addition; lift-and-shift prep).**
|
|
215
|
+
- Create `src/lifecycle/subagent-state.ts` and `test/lifecycle/subagent-state.test.ts` (red → green for every transition/accumulator).
|
|
216
|
+
- Refactor `Subagent` to hold a `SubagentState` built from the *existing* `SubagentInit` fields; getters/mutators delegate. `SubagentStatus` moves to `subagent-state.ts`, re-exported from `subagent.ts`.
|
|
217
|
+
- In `subagent.test.ts`, introduce a local construction helper and route call sites through it; move the pure state-machine `describe` blocks into `subagent-state.test.ts`.
|
|
218
|
+
- No consumer breaks (init shape unchanged). `pnpm run check` + `test`.
|
|
219
|
+
- Commit: `refactor: extract SubagentState value object (#373)`.
|
|
220
|
+
2. **Retarget the observer at `SubagentState`.**
|
|
221
|
+
- Change `subscribeSubagentObserver` to accept `SubagentState` and an `onCompact(info)`; update the two `subagent.ts` call sites (pass `this.state`, close over `this`).
|
|
222
|
+
- Rewrite `record-observer.test.ts` to build a `SubagentState`.
|
|
223
|
+
- Commit: `refactor: target SubagentState in subagent observer (#373)`.
|
|
224
|
+
3. **Make execution mandatory (the atomic construction flip).**
|
|
225
|
+
- Add `SubagentExecution`; rewrite `SubagentInit` to the 5-field shape; remove flat execution/run-config fields and `isBackground?`; delete the two `run()` throws; read from `this.execution`.
|
|
226
|
+
- Update the single production site (`subagent-manager.ts spawn`) and `make-subagent.ts` (`makeStubExecution` + `state`) and the `subagent.test.ts` helper/`createRunnableAgent`/`createResumableAgent` in the **same commit** — removing the optional fields breaks every construction at the type level simultaneously.
|
|
227
|
+
- Adjust the now-obsolete "missing session factory" test (throw is gone).
|
|
228
|
+
- `pnpm run check`, `lint`, `test`, `fallow dead-code`.
|
|
229
|
+
- Commit: `refactor: make Subagent execution deps mandatory (#373)`.
|
|
230
|
+
4. **Docs.**
|
|
231
|
+
- Update `architecture.md` (file listing, class diagram, mark Step 2 complete, prose, type table) and `SKILL.md` (domain counts).
|
|
232
|
+
- Commit: `docs: record SubagentState extraction in architecture and skill (#373)`.
|
|
233
|
+
|
|
234
|
+
Optionally run `pnpm run verify:public-types` after Step 3 to confirm the public bundle is unaffected (expected: no change — the bundle does not reference `SubagentInit`).
|
|
235
|
+
|
|
236
|
+
## Risks and Mitigations
|
|
237
|
+
|
|
238
|
+
- **Large test file churn (`subagent.test.ts`, ~700 LOC).**
|
|
239
|
+
Mitigated by lift-and-shift: Step 1 funnels constructions through a local helper and moves state tests out, so Step 3's mandatory-execution flip edits the helper + two run/resume factories rather than rewriting the file.
|
|
240
|
+
- **Initial-status regression for the limiter.**
|
|
241
|
+
Production explicitly sets `state` status in `spawn`; a `subagent-manager` test should assert a background spawn reports `queued` before its slot opens (existing coverage; re-verify).
|
|
242
|
+
- **`SubagentStatus` re-export path.**
|
|
243
|
+
Keep `SubagentStatus` re-exported from `subagent.ts`; `verify:public-types` (and `pnpm run check`) confirm `service.ts` still resolves it.
|
|
244
|
+
- **Circular import (`subagent.ts` ↔ `subagent-state.ts`).**
|
|
245
|
+
Avoided: `subagent-state.ts` defines `SubagentStatus` and imports nothing from `subagent.ts`; `subagent.ts` imports `SubagentState`/`SubagentStatus` from `subagent-state.ts`.
|
|
246
|
+
|
|
247
|
+
## Open Questions
|
|
248
|
+
|
|
249
|
+
- Whether the observer should accept a narrow `{ incrementToolUses; addUsage; incrementCompactions }` interface instead of `SubagentState` concretely — deferred; revisit if a second consumer of the stats sink appears (defer-until-needed, per the metrics-projection work in a later phase).
|
|
250
|
+
- Whether the `make-subagent.ts` stat shorthands should construct `SubagentState` directly rather than delegating through `Subagent` — cosmetic; left until Step 7's fixture consolidation (#378).
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
---
|
|
2
|
+
issue: 403
|
|
3
|
+
issue_title: "Pressing Escape does not stop subagent/background agent"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Abort subagents on parent interrupt (ESC)
|
|
7
|
+
|
|
8
|
+
## Problem Statement
|
|
9
|
+
|
|
10
|
+
A user reports that pressing Escape in the Pi terminal to cancel the current work does not stop a running subagent — the agent keeps going despite the cancel request.
|
|
11
|
+
The reporter is a third party (`khalid244`); the operator confirmed the direction is to implement ESC-to-abort for both foreground and background subagents, aborting all running and queued background agents on a single ESC.
|
|
12
|
+
|
|
13
|
+
The root cause splits cleanly by execution mode:
|
|
14
|
+
|
|
15
|
+
1. Foreground subagents already receive the parent's abort signal through the tool boundary (`tool.execute(signal)` → `Subagent.wireSignal` → `abort()` → child `session.abort()`), so they should already stop on ESC.
|
|
16
|
+
2. Background subagents are detached by design: `spawnBackground()` never forwards the parent signal, and `manager.abortAll()` runs only on `session_shutdown`.
|
|
17
|
+
There is no wiring from a parent interrupt to background-agent abort, so ESC does nothing to them.
|
|
18
|
+
This is the reproducible bug.
|
|
19
|
+
|
|
20
|
+
## Goals
|
|
21
|
+
|
|
22
|
+
- Pressing ESC (the parent agent-loop interrupt) aborts all running and queued background subagents.
|
|
23
|
+
- Add a regression guard test proving a foreground subagent's child session is aborted when the parent signal fires.
|
|
24
|
+
- Reuse the existing `manager.abortAll()` semantics (abort running, mark queued stopped, clear the limiter) so ESC stops every active subagent in one action.
|
|
25
|
+
|
|
26
|
+
This is an intentional behavior change: background subagents that previously survived ESC will now stop.
|
|
27
|
+
It is a bug fix (`fix:`), not a breaking change — no config key, default value, or output shape changes, and detached-survives-ESC was a limitation rather than a contract.
|
|
28
|
+
|
|
29
|
+
## Non-Goals
|
|
30
|
+
|
|
31
|
+
- Selective or interactive abort (choosing which agent to stop) — out of scope.
|
|
32
|
+
- A dedicated `abortBackground()` that excludes foreground agents — `abortAll()` is reused; foreground agents are already aborted by their own signal wiring, so the overlap is redundant-but-harmless.
|
|
33
|
+
- Changing background-agent detachment for any path other than the ESC interrupt (e.g., the tool still returns immediately on spawn).
|
|
34
|
+
- Confirmation prompts or status messaging on abort.
|
|
35
|
+
|
|
36
|
+
## Background
|
|
37
|
+
|
|
38
|
+
Relevant modules and the verified runtime facts behind the design:
|
|
39
|
+
|
|
40
|
+
- `src/tools/foreground-runner.ts` — `runForeground(..., signal, ...)` forwards the parent `signal` into `manager.spawnAndWait({ signal })`.
|
|
41
|
+
- `src/lifecycle/subagent.ts` — `run()` calls `this.wireSignal(this.execution.signal, () => this.abort())`; `abort()` fires `abortController.abort()` and marks the record stopped.
|
|
42
|
+
- `src/lifecycle/subagent-session.ts` — `runTurnLoop` calls `forwardAbortSignal(session, opts.signal)`, which calls `session.abort()` when the signal fires.
|
|
43
|
+
- `src/tools/background-spawner.ts` — `spawnBackground()` omits `signal` entirely; background agents are detached.
|
|
44
|
+
- `src/lifecycle/subagent-manager.ts` — `abortAll()` aborts running, marks queued stopped, and clears the limiter; currently called only from `src/handlers/lifecycle.ts` on shutdown.
|
|
45
|
+
- `src/handlers/tool-start.ts`, `src/handlers/lifecycle.ts`, `src/handlers/index.ts` — the existing `handlers/` pattern: small classes with a narrow injected interface, registered in `index.ts`.
|
|
46
|
+
|
|
47
|
+
Verified SDK facts (from the pinned peer deps under `node_modules/@earendil-works/`):
|
|
48
|
+
|
|
49
|
+
- The interactive ESC handler calls `agent.abort()` while streaming (`pi-coding-agent` `interactive-mode.js`, `restoreQueuedMessagesToEditor({ abort: true })`).
|
|
50
|
+
- `pi-agent-core` `agent.js`: each run creates a fresh `AbortController`; `agent.abort()` calls `activeRun.abortController.abort()`; on normal completion `finishRun()` discards the controller **without** aborting it.
|
|
51
|
+
Therefore the parent signal's `abort` event fires only on a real interrupt, never on normal turn completion — latching `abortAll()` to it will not spuriously kill background agents at turn end.
|
|
52
|
+
- The signal passed to `tool.execute(...)` (`agent-loop.js` line ~419) is that same per-run signal.
|
|
53
|
+
- Extensions read the live per-run parent signal via `ctx.signal` (`ExtensionContext.signal: AbortSignal | undefined`, undefined when idle).
|
|
54
|
+
- `pi.on("turn_start", (event, ctx) => ...)` is a registered event whose handler receives `ExtensionContext`; `turn_start` fires once at the start of every turn while streaming, so its `ctx.signal` is always the current run's signal.
|
|
55
|
+
|
|
56
|
+
AGENTS.md constraint: pi-subagents is a minimal core with dependency arrows pointing inward.
|
|
57
|
+
The new handler depends only on a narrow manager interface; no consumer knowledge leaks into the manager.
|
|
58
|
+
|
|
59
|
+
## Design Overview
|
|
60
|
+
|
|
61
|
+
Add a small `InterruptHandler` that latches the current parent abort signal and, on abort, tells the manager to abort all subagents.
|
|
62
|
+
Drive it from `turn_start` so the latch always tracks the live per-run signal — including across runs and turns that execute no tools.
|
|
63
|
+
|
|
64
|
+
Why `turn_start` rather than `tool_execution_start`: a background agent can outlive the run that spawned it.
|
|
65
|
+
If the user later interrupts a turn that ran no subagent tool, only a turn-level latch still holds that run's signal.
|
|
66
|
+
`turn_start` fires every turn with the current `ctx.signal`, so the latch is always current.
|
|
67
|
+
|
|
68
|
+
The latch dedups by reference: most turns reuse the same signal (no-op); a new run's signal triggers a detach-and-rewire.
|
|
69
|
+
The `abort` listener is `{ once: true }`; on normal completion the run's `AbortController` is discarded and garbage-collected with its listener, and the next `turn_start` detaches the stale reference.
|
|
70
|
+
|
|
71
|
+
### Manager interface (narrow, Tell-Don't-Ask)
|
|
72
|
+
|
|
73
|
+
```typescript
|
|
74
|
+
/** Narrow manager interface — only the method the interrupt handler calls. */
|
|
75
|
+
export interface InterruptManager {
|
|
76
|
+
abortAll(): number;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Minimal context shape — only the field the handler reads. */
|
|
80
|
+
interface InterruptCtx {
|
|
81
|
+
signal: AbortSignal | undefined;
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### Handler
|
|
86
|
+
|
|
87
|
+
```typescript
|
|
88
|
+
export class InterruptHandler {
|
|
89
|
+
private latched?: AbortSignal;
|
|
90
|
+
private detach?: () => void;
|
|
91
|
+
|
|
92
|
+
constructor(private readonly manager: InterruptManager) {}
|
|
93
|
+
|
|
94
|
+
handleTurnStart(ctx: InterruptCtx): void {
|
|
95
|
+
const signal = ctx.signal;
|
|
96
|
+
if (signal === this.latched) return;
|
|
97
|
+
this.detach?.();
|
|
98
|
+
this.detach = undefined;
|
|
99
|
+
this.latched = signal;
|
|
100
|
+
if (!signal) return;
|
|
101
|
+
const onAbort = (): void => {
|
|
102
|
+
this.manager.abortAll();
|
|
103
|
+
};
|
|
104
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
105
|
+
this.detach = () => signal.removeEventListener("abort", onAbort);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### Consumer call site (`index.ts`)
|
|
111
|
+
|
|
112
|
+
```typescript
|
|
113
|
+
const interrupt = new InterruptHandler(manager);
|
|
114
|
+
pi.on("turn_start", (_event, ctx) => interrupt.handleTurnStart(ctx));
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
The handler talks to `manager` through a one-method interface, reads one field of `ctx`, and performs no chained access — no Law-of-Demeter or output-argument smells.
|
|
118
|
+
The latch state (current signal, detach handle) is owned by the handler.
|
|
119
|
+
|
|
120
|
+
### Edge cases
|
|
121
|
+
|
|
122
|
+
- Same signal across consecutive turns → reference equality short-circuits; no listener churn.
|
|
123
|
+
- `ctx.signal` undefined (idle, defensive) → detach the old listener and hold no signal.
|
|
124
|
+
- Signal already aborted when latched → `{ once: true }` listener does not fire; the prior signal's listener already ran `abortAll()`, so no agent is missed.
|
|
125
|
+
- ESC during a foreground subagent → the foreground agent is aborted twice (once via its own `wireSignal`, once via `abortAll`); `abort()` is guarded by status and `markStopped` is idempotent, so this is harmless.
|
|
126
|
+
|
|
127
|
+
## Module-Level Changes
|
|
128
|
+
|
|
129
|
+
- `src/handlers/interrupt.ts` (new) — `InterruptHandler` class and `InterruptManager` interface.
|
|
130
|
+
- `src/handlers/index.ts` — add `export { InterruptHandler } from "#src/handlers/interrupt";`.
|
|
131
|
+
- `src/index.ts` — instantiate `new InterruptHandler(manager)` and register `pi.on("turn_start", (_event, ctx) => interrupt.handleTurnStart(ctx))`.
|
|
132
|
+
- `src/lifecycle/subagent-manager.ts` — no code change; `abortAll()` is reused.
|
|
133
|
+
Its `// fallow-ignore-next-line unused-class-member` comment stays (it is still reached only through narrow interfaces that fallow does not trace); the pre-completion `fallow dead-code` check will confirm.
|
|
134
|
+
- `docs/architecture/architecture.md` — extend the `handlers/` directory listing (around line 354) with `interrupt.ts` (turn_start handler → abort all subagents on interrupt).
|
|
135
|
+
Check the same file for any handler file-count or complexity row that names the `handlers/` domain and update if present.
|
|
136
|
+
|
|
137
|
+
No exports are removed or renamed.
|
|
138
|
+
Grep confirms `.pi/skills/package-pi-subagents/SKILL.md` does not mention `abortAll`, interrupt, or ESC, so no skill update is required.
|
|
139
|
+
|
|
140
|
+
## Test Impact Analysis
|
|
141
|
+
|
|
142
|
+
This is a feature/fix addition, not an extraction, so no existing tests become redundant.
|
|
143
|
+
|
|
144
|
+
1. New unit tests enabled — `InterruptHandler`: latches the current signal, fires `abortAll()` on abort, dedups the same signal reference, re-wires on a new signal, and handles an undefined signal.
|
|
145
|
+
2. New integration guard — foreground abort: aborting the parent signal passed to `runTurnLoop` invokes the child `session.abort()`.
|
|
146
|
+
This pins the currently-untested foreground link in `forwardAbortSignal`.
|
|
147
|
+
3. Existing tests stay as-is — `test/lifecycle/subagent.test.ts` (`wireSignal`, `abort`), `test/lifecycle/subagent-session.test.ts` (max-turns abort path), and `test/handlers/lifecycle.test.ts` (`abortAll` on shutdown) continue to exercise their layers unchanged.
|
|
148
|
+
|
|
149
|
+
## TDD Order
|
|
150
|
+
|
|
151
|
+
1. Foreground guard — `test/lifecycle/subagent-session.test.ts`.
|
|
152
|
+
Add a test: when the `signal` passed to `runTurnLoop` aborts while `session.prompt` is in flight, `session.abort()` is called.
|
|
153
|
+
Expected to pass immediately (proving the foreground chain already works); if the trace is wrong and it fails, fix `forwardAbortSignal` in `src/lifecycle/subagent-session.ts`.
|
|
154
|
+
Commit `test: guard foreground subagent abort on parent signal (#403)` (or `fix:` if a code fix is needed).
|
|
155
|
+
2. Interrupt handler + wiring — `test/handlers/interrupt.test.ts` (new) → `src/handlers/interrupt.ts`, `src/handlers/index.ts`, `src/index.ts`.
|
|
156
|
+
Red: write the handler unit tests (latch, abort→abortAll, dedup, re-wire, undefined signal) against the not-yet-existing class.
|
|
157
|
+
Green: implement `InterruptHandler` + `InterruptManager`, export from the barrel, and register `pi.on("turn_start", ...)` in `index.ts`.
|
|
158
|
+
The handler, its test, and the composition-root wiring land together because the handler is inert without the registration.
|
|
159
|
+
Commit `fix: abort all subagents on parent interrupt (#403)`.
|
|
160
|
+
3. Architecture doc — `docs/architecture/architecture.md`.
|
|
161
|
+
Add `interrupt.ts` to the `handlers/` directory listing and update any handler-domain count/row if present.
|
|
162
|
+
Commit `docs: note interrupt handler in subagents architecture (#403)`.
|
|
163
|
+
|
|
164
|
+
## Risks and Mitigations
|
|
165
|
+
|
|
166
|
+
- ESC now stops background agents the user might have wanted to keep running.
|
|
167
|
+
Mitigation: this is the operator's explicit choice (abort all running + queued); the behavior is documented in the plan and reflected in the `fix:` commit body.
|
|
168
|
+
- Re-latching on every `turn_start` could add overhead.
|
|
169
|
+
Mitigation: the latch is a single reference comparison and short-circuits on the common same-signal case.
|
|
170
|
+
- A `{ once: true }` listener lingers on a signal that completes normally.
|
|
171
|
+
Mitigation: the run's `AbortController` is discarded and GC'd with its listener; the next `turn_start` detaches the stale handle.
|
|
172
|
+
- Non-interactive modes (print/rpc) may not emit `turn_start` the same way.
|
|
173
|
+
Mitigation: ESC interrupt is an interactive concern; the handler is a no-op when no signal is present.
|
|
174
|
+
|
|
175
|
+
## Open Questions
|
|
176
|
+
|
|
177
|
+
- Should a dedicated `abortBackground()` (excluding foreground) replace `abortAll()` here?
|
|
178
|
+
Deferred: `abortAll()` is simpler and foreground is already signal-aborted; revisit only if the redundant double-abort proves problematic.
|
|
179
|
+
- Should ESC abort surface a confirmation or status message?
|
|
180
|
+
Deferred: out of scope for this fix.
|