@gotgenes/pi-subagents 16.1.1 → 16.2.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 +14 -0
- package/docs/architecture/architecture.md +31 -24
- package/docs/plans/0374-encapsulate-subagent-start-notification.md +268 -0
- package/docs/plans/0375-extract-run-listener-workspace-bracket.md +300 -0
- package/docs/retro/0374-encapsulate-subagent-start-notification.md +171 -0
- package/docs/retro/0375-extract-run-listener-workspace-bracket.md +51 -0
- package/docs/retro/0403-abort-subagents-on-interrupt.md +41 -0
- package/package.json +1 -1
- package/src/lifecycle/run-listeners.ts +37 -0
- package/src/lifecycle/subagent-manager.ts +5 -7
- package/src/lifecycle/subagent.ts +76 -83
- package/src/lifecycle/workspace-bracket.ts +59 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,20 @@ 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.2.1](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v16.2.0...pi-subagents-v16.2.1) (2026-06-15)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* restore at-spawn promise for queued subagents ([#374](https://github.com/gotgenes/pi-packages/issues/374)) ([4f08c6c](https://github.com/gotgenes/pi-packages/commit/4f08c6c37814b5386a23cd60479efc39c4b22612))
|
|
14
|
+
|
|
15
|
+
## [16.2.0](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v16.1.1...pi-subagents-v16.2.0) (2026-06-14)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
### Features
|
|
19
|
+
|
|
20
|
+
* encapsulate Subagent.start(), promise, and notification ([#374](https://github.com/gotgenes/pi-packages/issues/374)) ([048b4a0](https://github.com/gotgenes/pi-packages/commit/048b4a0a859ec83e1c73c1386484a747e37ba224))
|
|
21
|
+
|
|
8
22
|
## [16.1.1](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v16.1.0...pi-subagents-v16.1.1) (2026-06-14)
|
|
9
23
|
|
|
10
24
|
|
|
@@ -130,9 +130,6 @@ classDiagram
|
|
|
130
130
|
+completeRun(result)
|
|
131
131
|
+failRun(err)
|
|
132
132
|
+disposeSession()
|
|
133
|
-
+wireSignal(signal, onAbort)
|
|
134
|
-
+attachObserver(unsub)
|
|
135
|
-
+releaseListeners()
|
|
136
133
|
}
|
|
137
134
|
|
|
138
135
|
class SubagentState {
|
|
@@ -309,8 +306,10 @@ src/
|
|
|
309
306
|
│ ├── create-subagent-session.ts assembly factory: session creation, binding, tool filtering
|
|
310
307
|
│ ├── subagent-session.ts born-complete child session: turn loop, steer, dispose
|
|
311
308
|
│ ├── turn-limits.ts normalizeMaxTurns (turn-count policy)
|
|
312
|
-
│ ├── subagent.ts owns full execution lifecycle (run, abort, steer
|
|
309
|
+
│ ├── subagent.ts owns full execution lifecycle (run, abort, steer)
|
|
313
310
|
│ ├── subagent-state.ts lifecycle status + metrics value object (transitions, accumulators)
|
|
311
|
+
│ ├── run-listeners.ts per-run observer-unsub and signal-detach handles
|
|
312
|
+
│ ├── workspace-bracket.ts child workspace prepare/dispose lifecycle
|
|
314
313
|
│ ├── concurrency-limiter.ts background admission gate: schedules run thunks FIFO against the limit
|
|
315
314
|
│ ├── parent-snapshot.ts immutable spawn-time parent state
|
|
316
315
|
│ ├── child-lifecycle.ts child-execution lifecycle event publisher
|
|
@@ -644,17 +643,17 @@ That method — testability friction as a boundary probe, with its limits — is
|
|
|
644
643
|
|
|
645
644
|
### Health metrics
|
|
646
645
|
|
|
647
|
-
| Metric | Value
|
|
648
|
-
| -------------------------- |
|
|
649
|
-
| Health score | 78/100 (B)
|
|
650
|
-
| Total LOC |
|
|
651
|
-
| Dead code | 0 files, 0 exports
|
|
652
|
-
| Maintainability index | 90.8 (good)
|
|
653
|
-
| Avg cyclomatic complexity | 1.4
|
|
654
|
-
| P90 cyclomatic complexity | 2
|
|
655
|
-
| Production duplication | 11 lines (1 internal clone group)
|
|
656
|
-
| Test duplication | 42 clone groups, 661 lines
|
|
657
|
-
| Fallow refactoring targets | 0
|
|
646
|
+
| Metric | Value |
|
|
647
|
+
| -------------------------- | --------------------------------------- |
|
|
648
|
+
| Health score | 78/100 (B) |
|
|
649
|
+
| Total LOC | 8,356 (60 files, as of Phase 17 Step 4) |
|
|
650
|
+
| Dead code | 0 files, 0 exports |
|
|
651
|
+
| Maintainability index | 90.8 (good) |
|
|
652
|
+
| Avg cyclomatic complexity | 1.4 |
|
|
653
|
+
| P90 cyclomatic complexity | 2 |
|
|
654
|
+
| Production duplication | 11 lines (1 internal clone group) |
|
|
655
|
+
| Test duplication | 42 clone groups, 661 lines |
|
|
656
|
+
| Fallow refactoring targets | 0 |
|
|
658
657
|
|
|
659
658
|
### Dependency bag inventory
|
|
660
659
|
|
|
@@ -894,7 +893,7 @@ Updated health metrics (fallow, package-wide including tests):
|
|
|
894
893
|
| Metric | Phase 16 baseline | Current |
|
|
895
894
|
| -------------------------- | ------------------------------ | --------------------------------------------- |
|
|
896
895
|
| Health score | 78/100 (B) | 78/100 (B) |
|
|
897
|
-
| Source LOC | 7,778 (57 files) |
|
|
896
|
+
| Source LOC | 7,778 (57 files) | 8,356 (60 files, landed Phase 17 Step 4) |
|
|
898
897
|
| Dead code | 0 files, 0 exports | 0 files, 0 exports |
|
|
899
898
|
| Maintainability index | 90.8 (good) | 90.8 (good) |
|
|
900
899
|
| Avg / P90 cyclomatic | 1.4 / 2 | 1.4 / 2 |
|
|
@@ -940,7 +939,7 @@ Priority = Impact × (6 − Risk).
|
|
|
940
939
|
- Targets: `src/lifecycle/concurrency-queue.ts` (→ `concurrency-limiter.ts`), `src/lifecycle/subagent-manager.ts`, `src/index.ts`, `test/lifecycle/concurrency-queue.test.ts`, `test/lifecycle/subagent-manager.test.ts`.
|
|
941
940
|
- Smell: Category C (forward references: the queue's ID-registry design forces a start callback that reaches back into the manager, duplicated between `index.ts` and the test helper) and Category A (dual counting: the queue's `running` counter is fed by `markStarted`/`markFinished` relays in the manager's observer, mirroring state the agents already carry).
|
|
942
941
|
- Change: replace the ID-registry queue with a `ConcurrencyLimiter` that schedules thunks FIFO against a dynamic `getLimit()` — the injected limiter knows nothing about agents, IDs, or the manager.
|
|
943
|
-
Spawn gates background runs with `limiter.schedule(() => record.
|
|
942
|
+
Spawn gates background runs with `limiter.schedule(() => record.start())` — `start()` owns the abort-while-queued status guard and stores the promise internally; foreground and `bypassQueue` runs invoke `record.start()` directly.
|
|
944
943
|
The settings `onMaxConcurrentChanged` hook wires to `limiter.recheck()` in `index.ts`; `dispose()` calls `limiter.clear()` to drop pending thunks.
|
|
945
944
|
- 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.
|
|
946
945
|
|
|
@@ -960,19 +959,27 @@ Priority = Impact × (6 − Risk).
|
|
|
960
959
|
`subscribeSubagentObserver` targets `SubagentState`, so observer and state-machine tests no longer stub execution.
|
|
961
960
|
`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.
|
|
962
961
|
|
|
963
|
-
#### Step 3 — Encapsulate run start and notification attachment on Subagent ([#374])
|
|
962
|
+
#### Step 3 — Encapsulate run start and notification attachment on Subagent ([#374]) ✅ Complete
|
|
964
963
|
|
|
965
|
-
- Targets: `src/lifecycle/subagent.ts`, `src/lifecycle/subagent-manager.ts`, `test/tools/get-result-tool.test.ts`, `test/lifecycle/subagent-manager.test.ts`, `test/service/service-adapter.test.ts`, `test/observation/notification.test.ts`, `test/helpers/make-subagent.ts`.
|
|
966
|
-
- Smell: Category C — output arguments: external writes to `record.promise` (
|
|
967
|
-
- Change: add `Subagent.start()` that runs and stores its own promise (plus an awaitable accessor for `spawnAndWait`/`waitForAll`); make `promise` and `notification` externally read-only; tests
|
|
968
|
-
- Outcome: zero external writes to `Subagent` fields outside its own methods (grep-verifiable: `\.promise =` and `\.notification =` appear only inside `subagent.ts`).
|
|
964
|
+
- Targets: `src/lifecycle/subagent.ts`, `src/lifecycle/subagent-manager.ts`, `test/tools/get-result-tool.test.ts`, `test/lifecycle/subagent-manager.test.ts`, `test/service/service-adapter.test.ts`, `test/observation/notification.test.ts`, `test/helpers/make-subagent.test.ts`, `test/lifecycle/subagent.test.ts`.
|
|
965
|
+
- Smell: Category C — output arguments: external writes to `record.promise` (2 production sites in `subagent-manager.ts`, 4 test sites) and `record.notification` (7 test sites; the production path was resolved in Step 2 — the constructor creates `notification` from `execution.parentSession?.toolCallId`, so Step 3's remaining work is making the field read-only and updating tests to supply it via `parentSession`).
|
|
966
|
+
- Change: add `Subagent.start()` that runs and stores its own promise (plus an awaitable accessor for `spawnAndWait`/`waitForAll`); make `promise` and `notification` externally read-only (private `_promise`/`_notification` fields backed by public getters); the abort-while-queued status guard folds into `start()`, removing the inline check from the limiter callback; tests use `createTestSubagent({ toolCallId })` or spawn with `parentSession.toolCallId` instead of post-construction assignment.
|
|
967
|
+
- Outcome: zero external writes to `Subagent` fields outside its own methods (grep-verifiable: `\.promise =` and `\.notification =` appear only inside `subagent.ts`); 6 new unit tests for `start()` behaviour; test count +6 (975 → 981).
|
|
968
|
+
- Landed: `Subagent.start()` (immediate path) and `Subagent.scheduleVia(schedule)` (queued path) own the promise and the shared `guardedRun()` status guard; `SubagentManager.spawn()` calls one or the other; `TestSubagentOptions.toolCallId` wires notification state via the constructor path.
|
|
969
|
+
- Correction (post-merge): the first cut used `void this.limiter.schedule(() => record.start())`, which left a queued agent's `promise` unset until its slot opened — silently regressing Step 1's "every spawned agent has a `promise` at spawn" invariant.
|
|
970
|
+
Fixed by inverting control: `scheduleVia` captures the limiter promise eagerly inside the agent (no external `.promise =` write), restoring the invariant.
|
|
971
|
+
Lesson: a step's acceptance criteria must include the cross-step invariants it could regress, not only its own grep-verifiable outcome.
|
|
969
972
|
|
|
970
973
|
#### Step 4 — Extract run-listener and workspace-bracket collaborators from Subagent ([#375])
|
|
971
974
|
|
|
972
|
-
- Targets: `src/lifecycle/subagent.ts` (
|
|
975
|
+
- Targets: `src/lifecycle/subagent.ts` (455 LOC after Step 2 extracted SubagentState — still the largest source file).
|
|
973
976
|
- Smell: Category B (oversized class; per-run listener fields declared mid-class) and Category C (state owns its mutations: workspace dispose logic appears in `run()`'s catch, `completeRun`, and `failRun`).
|
|
974
|
-
- Change: extract a `RunListeners` object owning the observer-unsubscribe and signal-detach handles (`
|
|
977
|
+
- Change: extract a `RunListeners` object owning the observer-unsubscribe and signal-detach handles (`wireSignal`/`attachObserver`/`release`), and a `WorkspaceBracket` collaborator owning prepare/dispose-with-addendum, centralising the dispose logic.
|
|
975
978
|
- Outcome: `subagent.ts` ≤ 450 LOC; workspace disposal logic in exactly one place; listener handles no longer raw nullable fields.
|
|
979
|
+
- Landed: `RunListeners` (`src/lifecycle/run-listeners.ts`) owns the signal-detach and observer-unsub handles with a single `release()` call; `WorkspaceBracket` (`src/lifecycle/workspace-bracket.ts`) owns prepare-at-run-start and dispose-with-addendum — `completeRun` and `failRun` call `workspaceBracket.dispose(outcome)` and receive the addendum string (or `""`) without reaching through to the workspace object directly.
|
|
980
|
+
`Subagent.wireSignal`, `attachObserver`, and `releaseListeners` are removed.
|
|
981
|
+
`subagent.ts`: 488 → 448 LOC.
|
|
982
|
+
Test count: 982 → 994 (+12: 7 RunListeners + 13 WorkspaceBracket − 8 redundant Subagent listener tests).
|
|
976
983
|
|
|
977
984
|
#### Step 5 — Extract the manager observer from index.ts into a class ([#376])
|
|
978
985
|
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
---
|
|
2
|
+
issue: 374
|
|
3
|
+
issue_title: "Encapsulate run start and notification attachment on Subagent"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Encapsulate Subagent.start() and read-only promise/notification
|
|
7
|
+
|
|
8
|
+
## Problem Statement
|
|
9
|
+
|
|
10
|
+
`Subagent.promise` is assigned from outside the class in three places — `SubagentManager.spawn()` (two sites: scheduled and immediate paths) — and `record.notification` is assigned from outside the class in seven test sites.
|
|
11
|
+
Both are output-argument smells (design-review check 3): the object should own the state its own methods read.
|
|
12
|
+
`Subagent.run()` already exists; the promise that tracks it lives outside the object purely so callers can `await record.promise`.
|
|
13
|
+
`notification` was already moved to the constructor in Phase 17 Step 2 (wired from `execution.parentSession?.toolCallId`), but the field is still publicly writable, so tests bypass the constructor path with direct assignment.
|
|
14
|
+
|
|
15
|
+
## Goals
|
|
16
|
+
|
|
17
|
+
- Add `Subagent.start()` that calls `run()`, stores the resulting promise internally, and returns it.
|
|
18
|
+
- Fold the abort-while-queued status guard into `start()`, removing the inline check from `SubagentManager`.
|
|
19
|
+
- Make `promise` externally read-only: private `_promise` field backed by a public `get promise()` accessor.
|
|
20
|
+
- Make `notification` externally read-only: private `_notification` field backed by a public `get notification()` accessor.
|
|
21
|
+
- Add `toolCallId?: string` to `TestSubagentOptions` so tests wire notification state via the constructor path without external writes.
|
|
22
|
+
- Achieve grep-verifiable outcome: `\.promise =` and `\.notification =` appear only inside `subagent.ts`.
|
|
23
|
+
|
|
24
|
+
## Non-Goals
|
|
25
|
+
|
|
26
|
+
- Extracting `RunListeners` or workspace-bracket collaborators from `Subagent` (Phase 17 Step 4, Issue [#375]).
|
|
27
|
+
- Extracting the manager observer from `index.ts` (Phase 17 Step 5, Issue [#376]).
|
|
28
|
+
- Any other Phase 17 step beyond Step 3.
|
|
29
|
+
|
|
30
|
+
## Background
|
|
31
|
+
|
|
32
|
+
Phase 17 Step 1 ([#381]) replaced `ConcurrencyQueue` with a `ConcurrencyLimiter` — the manager now calls `this.limiter.schedule(thunk)` and stores the scheduled promise on `record.promise`.
|
|
33
|
+
Phase 17 Step 2 ([#373]) extracted `SubagentState`, made `SubagentExecution` mandatory, and wired `notification` in the constructor via `execution.parentSession?.toolCallId`.
|
|
34
|
+
|
|
35
|
+
Current external write sites after Step 2:
|
|
36
|
+
|
|
37
|
+
| Field | Location | Count |
|
|
38
|
+
| --------------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------ |
|
|
39
|
+
| `record.promise` | `SubagentManager.spawn()` | 2 (scheduled + immediate) |
|
|
40
|
+
| `record.promise` | Test files | 3 (`get-result-tool.test.ts`, `service-adapter.test.ts`, `make-subagent.test.ts`) |
|
|
41
|
+
| `record.notification` | Test files | 7 (`get-result-tool.test.ts` ×2, `subagent-manager.test.ts` ×2, `service-adapter.test.ts` ×1, `notification.test.ts` ×2) |
|
|
42
|
+
|
|
43
|
+
`SubagentManager.spawnAndWait()` and `waitForAll()` read `record.promise` via the public field — these become getter reads after the change.
|
|
44
|
+
`get-result-tool.ts` reads `record.promise` to `await` it when `wait=true` — unchanged (getter).
|
|
45
|
+
|
|
46
|
+
The `AGENTS.md` constraint that applies: **output arguments** — if a function sets a field on a received object, it is doing work that belongs inside the owning object.
|
|
47
|
+
|
|
48
|
+
## Design Overview
|
|
49
|
+
|
|
50
|
+
### `Subagent.start()` and the status guard
|
|
51
|
+
|
|
52
|
+
```typescript
|
|
53
|
+
private _promise?: Promise<void>;
|
|
54
|
+
|
|
55
|
+
/** Awaitable handle to the running promise. Set by start(). */
|
|
56
|
+
get promise(): Promise<void> | undefined {
|
|
57
|
+
return this._promise;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Start execution: call run(), store the promise, and return it.
|
|
62
|
+
* Guards against non-active states (e.g. abort-while-queued): if the agent
|
|
63
|
+
* is neither queued nor running, the promise resolves immediately (no-op).
|
|
64
|
+
*/
|
|
65
|
+
start(): Promise<void> {
|
|
66
|
+
if (this.status !== "queued" && this.status !== "running") {
|
|
67
|
+
this._promise = Promise.resolve();
|
|
68
|
+
return this._promise;
|
|
69
|
+
}
|
|
70
|
+
this._promise = this.run();
|
|
71
|
+
return this._promise;
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The guard allows:
|
|
76
|
+
|
|
77
|
+
- `"queued"` — background agent waiting in the limiter; `run()` proceeds normally.
|
|
78
|
+
- `"running"` — foreground agent (status set to `"running"` at construction in the manager); `run()` proceeds normally.
|
|
79
|
+
- Any terminal state (`"stopped"`, `"error"`, `"completed"`, etc.) — agent was aborted while queued; `start()` becomes a no-op returning an immediately-resolving promise.
|
|
80
|
+
|
|
81
|
+
This folds the inline `if (record.status !== "queued") return Promise.resolve()` guard out of the `SubagentManager` limiter callback.
|
|
82
|
+
|
|
83
|
+
### `SubagentManager.spawn()` after the change
|
|
84
|
+
|
|
85
|
+
```typescript
|
|
86
|
+
// Queued background path
|
|
87
|
+
this.limiter.schedule(() => record.start());
|
|
88
|
+
|
|
89
|
+
// Immediate path (foreground or bypassQueue)
|
|
90
|
+
record.start();
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
`spawnAndWait()` continues to `await record.promise` (now uses the getter, no behavior change).
|
|
94
|
+
`waitForAll()`'s `pendingPromises()` continues to `r.promise` (getter — no behavior change).
|
|
95
|
+
|
|
96
|
+
### `notification` encapsulation
|
|
97
|
+
|
|
98
|
+
The constructor already writes to `this.notification` internally.
|
|
99
|
+
After the change, the constructor writes to `this._notification`:
|
|
100
|
+
|
|
101
|
+
```typescript
|
|
102
|
+
private _notification?: NotificationState;
|
|
103
|
+
|
|
104
|
+
get notification(): NotificationState | undefined {
|
|
105
|
+
return this._notification;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// In constructor:
|
|
109
|
+
const toolCallId = init.execution.parentSession?.toolCallId;
|
|
110
|
+
if (toolCallId) {
|
|
111
|
+
this._notification = new NotificationState(toolCallId);
|
|
112
|
+
}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
No production writes to `notification` outside the constructor — only test sites need updating.
|
|
116
|
+
|
|
117
|
+
### `TestSubagentOptions` shorthand
|
|
118
|
+
|
|
119
|
+
Add `toolCallId?: string` so tests that need a `NotificationState` use the constructor path:
|
|
120
|
+
|
|
121
|
+
```typescript
|
|
122
|
+
// Before
|
|
123
|
+
const record = createTestSubagent();
|
|
124
|
+
record.notification = new NotificationState("tc-1");
|
|
125
|
+
|
|
126
|
+
// After
|
|
127
|
+
const record = createTestSubagent({ toolCallId: "tc-1" });
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
In `createTestSubagent`, `toolCallId` routes through `makeStubExecution({ parentSession: { toolCallId } })`.
|
|
131
|
+
|
|
132
|
+
### Tests that write `record.promise`
|
|
133
|
+
|
|
134
|
+
- **`service-adapter.test.ts`** ("strips promise from the record" tests): the test only needs `promise` to be absent from the serialized output.
|
|
135
|
+
Since `toSubagentRecord()` already builds an explicit object without `promise`, these tests pass without any promise being set on the record.
|
|
136
|
+
Remove the `record.promise = ...` setup.
|
|
137
|
+
- **`make-subagent.test.ts`** ("allows setting promise directly"): the test's intent was to verify the field was settable.
|
|
138
|
+
Replace with a test that `start()` sets `promise` internally via the stub execution.
|
|
139
|
+
- **`get-result-tool.test.ts`** ("waits for promise when wait=true"): the test needs a running agent whose promise resolves and updates status to completed.
|
|
140
|
+
Replace with an execution stub where `runTurnLoop` returns `{ responseText: "Finished after wait.", aborted: false, steered: false }` and call `record.start()`.
|
|
141
|
+
The `createSubagentSessionStub()` default already resolves with `{ responseText: "done", ... }` — override `runTurnLoop` to return the expected text.
|
|
142
|
+
|
|
143
|
+
### `subagent-manager.test.ts` notification tests (lines 82, 100)
|
|
144
|
+
|
|
145
|
+
Tests that reproduce the race-condition bug (notification set post-spawn) become:
|
|
146
|
+
|
|
147
|
+
```typescript
|
|
148
|
+
const id = manager.spawn(STUB_SNAPSHOT, "general-purpose", "test", {
|
|
149
|
+
description: "bg",
|
|
150
|
+
isBackground: true,
|
|
151
|
+
parentSession: { toolCallId: "tc-1" },
|
|
152
|
+
});
|
|
153
|
+
const record = manager.getRecord(id)!;
|
|
154
|
+
// notification is already wired from the constructor
|
|
155
|
+
await record.promise;
|
|
156
|
+
record.notification?.markConsumed();
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
The behavior under test (race: `markConsumed()` after `await` is too late) is unchanged.
|
|
160
|
+
|
|
161
|
+
## Module-Level Changes
|
|
162
|
+
|
|
163
|
+
- `src/lifecycle/subagent.ts`
|
|
164
|
+
- Remove public writable `promise?: Promise<void>` field.
|
|
165
|
+
- Add `private _promise?: Promise<void>`.
|
|
166
|
+
- Add `get promise(): Promise<void> | undefined`.
|
|
167
|
+
- Add `start(): Promise<void>` with the status guard.
|
|
168
|
+
- Rename `this.notification` write in constructor to `this._notification`.
|
|
169
|
+
- Remove public writable `notification?: NotificationState` field.
|
|
170
|
+
- Add `private _notification?: NotificationState`.
|
|
171
|
+
- Add `get notification(): NotificationState | undefined`.
|
|
172
|
+
- `src/lifecycle/subagent-manager.ts`
|
|
173
|
+
- Replace `record.promise = this.limiter.schedule(() => { if (...) return ...; return record.run(); })` with `this.limiter.schedule(() => record.start())`.
|
|
174
|
+
- Replace `record.promise = record.run()` with `record.start()`.
|
|
175
|
+
- `test/helpers/make-subagent.ts`
|
|
176
|
+
- Add `toolCallId?: string` to `TestSubagentOptions`.
|
|
177
|
+
- In `createTestSubagent`, map `toolCallId` to `makeStubExecution({ parentSession: { toolCallId } })`.
|
|
178
|
+
- `test/helpers/make-subagent.test.ts`
|
|
179
|
+
- Replace "allows setting promise directly after construction" with a test that `start()` stores promise via the execution stub.
|
|
180
|
+
- `test/tools/get-result-tool.test.ts`
|
|
181
|
+
- Replace `record.promise = Promise.resolve().then(...)` setup with a stub execution + `record.start()`.
|
|
182
|
+
- Replace `record.notification = new NotificationState("tc-1")` (×2) with `createTestSubagent({ toolCallId: "tc-1" })`.
|
|
183
|
+
- `test/lifecycle/subagent-manager.test.ts`
|
|
184
|
+
- Replace `record.notification = new NotificationState("tc-1")` (×2) with spawn options carrying `parentSession: { toolCallId: "tc-1" }`.
|
|
185
|
+
- `test/service/service-adapter.test.ts`
|
|
186
|
+
- Remove `record.promise = Promise.resolve()` setup (×2) from tests that only need to verify `toSubagentRecord()` strips the field.
|
|
187
|
+
- Replace `record.notification = new NotificationState("tc-1")` with `createTestSubagent({ toolCallId: "tc-1" })`.
|
|
188
|
+
- `test/observation/notification.test.ts`
|
|
189
|
+
- Replace `record.notification = new NotificationState("tc-123/tc-1")` (×2) with `createTestSubagent({ toolCallId: "tc-123/tc-1" })`.
|
|
190
|
+
- `docs/architecture/architecture.md`
|
|
191
|
+
- Mark Step 3 `✅ Complete` and add a "Landed" note.
|
|
192
|
+
|
|
193
|
+
## Test Impact Analysis
|
|
194
|
+
|
|
195
|
+
1. **New unit tests enabled**: `start()` behavior (promise stored, status guard no-op) can be tested directly in `subagent.test.ts` without touching the manager.
|
|
196
|
+
2. **Existing tests simplified**: The 7 test sites that do `record.notification = ...` drop an artificial mutation and instead use the natural constructor path — the tests are shorter and closer to production semantics.
|
|
197
|
+
3. **Tests that must stay**: The manager's race-condition tests (lines 74–120) verify ordering of `markConsumed()` vs `await promise` — they change setup only (spawn with toolCallId), not intent.
|
|
198
|
+
4. **Tests removed**: The `make-subagent.test.ts` "allows setting promise" test is replaced, since direct write is no longer possible.
|
|
199
|
+
|
|
200
|
+
## TDD Order
|
|
201
|
+
|
|
202
|
+
1. **Add `Subagent.start()` alongside the existing public `promise?` field**
|
|
203
|
+
|
|
204
|
+
In `test/lifecycle/subagent.test.ts`, add tests:
|
|
205
|
+
- `start()` on a running agent returns a defined promise.
|
|
206
|
+
- `start()` on a stopped agent returns a resolving promise immediately (no-op guard).
|
|
207
|
+
- After `start()`, `record.promise` matches the returned promise.
|
|
208
|
+
|
|
209
|
+
In `src/lifecycle/subagent.ts`: add `private _promise`, `get promise()` (shadowing the old field — TypeScript will require removing the duplicate; advance to step 2 immediately), and `start()`.
|
|
210
|
+
Commit: `test: add Subagent.start() tests and initial implementation (#374)`
|
|
211
|
+
|
|
212
|
+
2. **Make `promise` read-only — remove public field, update all write sites**
|
|
213
|
+
|
|
214
|
+
Breaking change at the type level.
|
|
215
|
+
Atomic commit must include:
|
|
216
|
+
- `src/lifecycle/subagent.ts` — remove `promise?: Promise<void>` public field (only `private _promise` + getter remain).
|
|
217
|
+
- `src/lifecycle/subagent-manager.ts` — replace both `record.promise = ...` sites with `record.start()` calls; limiter thunk becomes `() => record.start()`.
|
|
218
|
+
- `test/helpers/make-subagent.test.ts` — replace write-promise test with `start()` test.
|
|
219
|
+
- `test/tools/get-result-tool.test.ts` — replace `record.promise = ...` setup; use execution stub + `record.start()`.
|
|
220
|
+
- `test/service/service-adapter.test.ts` — remove `record.promise = Promise.resolve()` setup (×2).
|
|
221
|
+
|
|
222
|
+
Run `pnpm --filter @gotgenes/pi-subagents run check` to verify.
|
|
223
|
+
Commit: `feat: make Subagent.promise read-only, add start() (#374)`
|
|
224
|
+
|
|
225
|
+
3. **Make `notification` read-only — remove public field, update all write sites**
|
|
226
|
+
|
|
227
|
+
Breaking change at the type level.
|
|
228
|
+
Atomic commit must include:
|
|
229
|
+
- `src/lifecycle/subagent.ts` — rename public `notification?` to `private _notification`; add `get notification()`; constructor write becomes `this._notification = ...`.
|
|
230
|
+
- `test/helpers/make-subagent.ts` — add `toolCallId?: string` to `TestSubagentOptions`; route through `makeStubExecution`.
|
|
231
|
+
- `test/tools/get-result-tool.test.ts` — replace `record.notification = new NotificationState(...)` (×2) with `createTestSubagent({ toolCallId: ... })`.
|
|
232
|
+
- `test/lifecycle/subagent-manager.test.ts` — replace `record.notification = new NotificationState(...)` (×2) with spawn options carrying `parentSession: { toolCallId: ... }`.
|
|
233
|
+
- `test/service/service-adapter.test.ts` — replace `record.notification = new NotificationState(...)` with `createTestSubagent({ toolCallId: ... })`.
|
|
234
|
+
- `test/observation/notification.test.ts` — replace `record.notification = new NotificationState(...)` (×2) with `createTestSubagent({ toolCallId: ... })`.
|
|
235
|
+
|
|
236
|
+
Run `pnpm --filter @gotgenes/pi-subagents exec vitest run` and `pnpm --filter @gotgenes/pi-subagents run check`.
|
|
237
|
+
Commit: `feat: make Subagent.notification read-only, update tests (#374)`
|
|
238
|
+
|
|
239
|
+
4. **Update architecture doc**
|
|
240
|
+
|
|
241
|
+
In `docs/architecture/architecture.md`, mark Step 3 `✅ Complete` and add a "Landed" note summarizing the outcome.
|
|
242
|
+
Also update the note at line 943 that says "Step 3 later folds the guard into `Subagent.start()`" to reflect it is now done.
|
|
243
|
+
Commit: `docs: mark Phase 17 Step 3 complete in architecture.md (#374)`
|
|
244
|
+
|
|
245
|
+
## Risks and Mitigations
|
|
246
|
+
|
|
247
|
+
- **Risk**: Adding both `private _promise` and `get promise()` while the public `promise?` field still exists is a TypeScript error (duplicate identifier).
|
|
248
|
+
**Mitigation**: Steps 1 and 2 are merged into one commit: introduce `start()`, remove the public writable field, and fix all consumers atomically.
|
|
249
|
+
The TDD order describes testing `start()` first, but both the public field removal and the consumer updates land in the same `feat:` commit.
|
|
250
|
+
- **Risk**: The status guard in `start()` allows `"running"` for foreground agents, which have `status = "running"` at construction.
|
|
251
|
+
If a foreground agent is stopped before `start()` is called (edge case), `run()` would call `markRunning()` on an already-stopped agent.
|
|
252
|
+
**Mitigation**: Foreground agents are started synchronously at the end of `spawn()` — there is no window between construction and `start()` during which the abort path can fire.
|
|
253
|
+
The guard is conservative and causes no regression.
|
|
254
|
+
- **Risk**: The race-condition test in `subagent-manager.test.ts` (lines 74–107) verifies that `markConsumed()` called after `await record.promise` is "too late" for the observer.
|
|
255
|
+
Switching from `record.notification = new NotificationState("tc-1")` to the constructor path does not change timing semantics.
|
|
256
|
+
**Mitigation**: The test body stays structurally identical; only the setup changes.
|
|
257
|
+
- **Risk**: `service-adapter.test.ts` tests that call `record.promise = Promise.resolve()` might be testing that the field exists on the Subagent type.
|
|
258
|
+
**Mitigation**: The tests are testing `toSubagentRecord()` output, not the field type.
|
|
259
|
+
Removing the setup doesn't change the assertion.
|
|
260
|
+
|
|
261
|
+
## Open Questions
|
|
262
|
+
|
|
263
|
+
- None.
|
|
264
|
+
The design is fully specified by the Phase 17 Step 3 architecture note and the existing class structure.
|
|
265
|
+
|
|
266
|
+
[#373]: https://github.com/gotgenes/pi-packages/issues/373
|
|
267
|
+
[#375]: https://github.com/gotgenes/pi-packages/issues/375
|
|
268
|
+
[#381]: https://github.com/gotgenes/pi-packages/issues/381
|