@gotgenes/pi-subagents 7.5.0 → 7.6.0
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 +25 -0
- package/docs/architecture/architecture.md +12 -9
- package/docs/plans/0215-decompose-build-parent-context.md +166 -0
- package/docs/plans/0216-decompose-start-agent.md +255 -0
- package/docs/retro/0215-decompose-build-parent-context.md +61 -0
- package/docs/retro/0216-decompose-start-agent.md +43 -0
- package/package.json +1 -1
- package/src/lifecycle/agent-manager.ts +132 -89
- package/src/lifecycle/worktree-state.ts +11 -1
- package/src/session/context.ts +25 -24
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,31 @@ 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
|
+
## [7.6.0](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v7.5.1...pi-subagents-v7.6.0) (2026-05-26)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* add WorktreeState.performCleanup for self-cleanup ([#216](https://github.com/gotgenes/pi-packages/issues/216)) ([ad0583a](https://github.com/gotgenes/pi-packages/commit/ad0583a9c26b6782af2a55ea86e72f3c3474ebe7))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
### Documentation
|
|
17
|
+
|
|
18
|
+
* plan decompose startAgent via RunHandle lifecycle object ([#216](https://github.com/gotgenes/pi-packages/issues/216)) ([2689571](https://github.com/gotgenes/pi-packages/commit/268957175c2aaa03da98c99778c6ff67e0bf45e3))
|
|
19
|
+
* **retro:** add planning stage notes for issue [#216](https://github.com/gotgenes/pi-packages/issues/216) ([06daa19](https://github.com/gotgenes/pi-packages/commit/06daa1923d75aae8aec1ddd492486c951e50a23f))
|
|
20
|
+
* **retro:** add retro notes for issue [#215](https://github.com/gotgenes/pi-packages/issues/215) ([57f7cf9](https://github.com/gotgenes/pi-packages/commit/57f7cf9139ce3d77f2ec91541bc67cd78c57bdb8))
|
|
21
|
+
* **retro:** add TDD stage notes for issue [#216](https://github.com/gotgenes/pi-packages/issues/216) ([4001da1](https://github.com/gotgenes/pi-packages/commit/4001da1faecc15d2c2c92e7fd69788d908ef5ad8))
|
|
22
|
+
* update architecture doc for [#216](https://github.com/gotgenes/pi-packages/issues/216) RunHandle decomposition ([8ad4a2a](https://github.com/gotgenes/pi-packages/commit/8ad4a2a2d25acdf7f2cd544f6b3cd3949edbc471))
|
|
23
|
+
|
|
24
|
+
## [7.5.1](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v7.5.0...pi-subagents-v7.5.1) (2026-05-26)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
### Documentation
|
|
28
|
+
|
|
29
|
+
* plan decompose buildParentContext ([#215](https://github.com/gotgenes/pi-packages/issues/215)) ([9103609](https://github.com/gotgenes/pi-packages/commit/910360991b50c320927c1457bfef6b7cb5624b7b))
|
|
30
|
+
* **retro:** add planning stage notes for issue [#215](https://github.com/gotgenes/pi-packages/issues/215) ([5c534d5](https://github.com/gotgenes/pi-packages/commit/5c534d5efb640ef1d72d6ccf7bf2e15ac2acf755))
|
|
31
|
+
* **retro:** add TDD stage notes for issue [#215](https://github.com/gotgenes/pi-packages/issues/215) ([79064d0](https://github.com/gotgenes/pi-packages/commit/79064d072c36c2f92013dbfba58ce1de1ab01bce))
|
|
32
|
+
|
|
8
33
|
## [7.5.0](https://github.com/gotgenes/pi-packages/compare/pi-subagents-v7.4.0...pi-subagents-v7.5.0) (2026-05-26)
|
|
9
34
|
|
|
10
35
|
|
|
@@ -652,20 +652,23 @@ Extract per-entry-type formatters: `formatMessageEntry(entry)` and `formatCompac
|
|
|
652
652
|
- Smell: B (oversized function)
|
|
653
653
|
- Outcome: cognitive complexity < 10, function < 15 LOC
|
|
654
654
|
|
|
655
|
-
### Step 3: Decompose `startAgent` in `agent-manager.ts` — [#216]
|
|
655
|
+
### Step 3: Decompose `startAgent` in `agent-manager.ts` — [#216] ✓
|
|
656
656
|
|
|
657
|
-
`startAgent`
|
|
658
|
-
|
|
657
|
+
`startAgent` had two mutable closure variables (`unsubRecordObserver`, `detachParentSignal`) shared across three callbacks with duplicated finalization logic in `.then()`/`.catch()`.
|
|
658
|
+
The fix introduced a `RunHandle` lifecycle object (private to `agent-manager.ts`) that owns the per-run cleanup state and exposes `complete()`/`fail()` as Tell-Don't-Ask methods.
|
|
659
|
+
`WorktreeState` gained `performCleanup(worktrees, description)` to eliminate the ask-tell dance at cleanup sites.
|
|
659
660
|
|
|
660
|
-
|
|
661
|
+
Extracted:
|
|
661
662
|
|
|
662
|
-
1. `
|
|
663
|
-
2. `
|
|
664
|
-
3. `
|
|
663
|
+
1. `RunHandle` class — owns `unsub`/`detachFn`, `wireSignal()`, `attachObserver()`, `complete()`, `fail()`, idempotent `fireOnFinished()`.
|
|
664
|
+
2. `finalizeBackgroundRun(record)` — shared `runningBackground--`, crash-safe observer notification, `drainQueue()`.
|
|
665
|
+
3. `setupWorktree(id, record, isolation)` — worktree creation with strict failure.
|
|
666
|
+
4. `flushPendingSteers(id, session)` — drain buffered steers on session creation.
|
|
667
|
+
5. `WorktreeState.performCleanup(worktrees, description)` — self-cleanup eliminating ask-tell.
|
|
665
668
|
|
|
666
|
-
- Target: `src/lifecycle/agent-manager.ts`
|
|
669
|
+
- Target: `src/lifecycle/agent-manager.ts`, `src/lifecycle/worktree-state.ts`
|
|
667
670
|
- Smell: B (oversized method) + A (duplicated finalization logic in then/catch)
|
|
668
|
-
- Outcome:
|
|
671
|
+
- Outcome: `startAgent` reduced to ~40 LOC coordinator with zero mutable `let` bindings; `.then()`/`.catch()` are one-liners
|
|
669
672
|
|
|
670
673
|
### Step 4: Extract overwrite guard from UI — [#217]
|
|
671
674
|
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
---
|
|
2
|
+
issue: 215
|
|
3
|
+
issue_title: "Decompose buildParentContext (cognitive 30) (Phase 13, Step 2)"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Decompose `buildParentContext`
|
|
7
|
+
|
|
8
|
+
## Problem Statement
|
|
9
|
+
|
|
10
|
+
`buildParentContext` in `src/session/context.ts` is the only remaining fallow refactoring target in the package.
|
|
11
|
+
The function has a cognitive complexity of 30, driven by a loop with three type-check branches (`message`, `compaction`, default), each with sub-branches for role (`user` vs `assistant`) and content type (`string` vs array).
|
|
12
|
+
The architecture roadmap (Phase 13, Step 2) targets cognitive complexity < 10 and function body < 15 LOC.
|
|
13
|
+
|
|
14
|
+
## Goals
|
|
15
|
+
|
|
16
|
+
- Extract per-entry-type formatters: `formatMessageEntry(entry)` and `formatCompactionEntry(entry)`.
|
|
17
|
+
- Reduce `buildParentContext` to a loop + filter + join orchestrator (< 15 LOC).
|
|
18
|
+
- Achieve cognitive complexity < 10 for all functions in the file.
|
|
19
|
+
- Add unit tests for the extracted formatters and the orchestrator.
|
|
20
|
+
|
|
21
|
+
## Non-Goals
|
|
22
|
+
|
|
23
|
+
- Changing the public API surface (`buildParentContext`, `extractText`) — signatures stay the same.
|
|
24
|
+
- Moving `extractText` to another module (noted as a follow-up in prior plans but out of scope).
|
|
25
|
+
- Refactoring callers (`parent-snapshot.ts`) — they are already tested via mocks.
|
|
26
|
+
|
|
27
|
+
## Background
|
|
28
|
+
|
|
29
|
+
### Current file: `src/session/context.ts`
|
|
30
|
+
|
|
31
|
+
The file exports two functions:
|
|
32
|
+
|
|
33
|
+
1. `extractText(content: unknown[]): string` — filters an array of content blocks to `TextContent` items and joins their `.text` values.
|
|
34
|
+
Used by `agent-runner.ts`, `message-formatters.ts`, and `buildParentContext` itself.
|
|
35
|
+
2. `buildParentContext(ctx: SessionContext): string` — iterates session branch entries, formatting `message` entries (user/assistant) and `compaction` entries into a text representation prefixed with a header.
|
|
36
|
+
|
|
37
|
+
The file also defines three local types (`MessageEntry`, `CompactionEntry`, `BranchEntry`) and one helper (`isTextContent`).
|
|
38
|
+
|
|
39
|
+
### Callers
|
|
40
|
+
|
|
41
|
+
- `buildParentContext` is called only from `parent-snapshot.ts` (where it is mocked in tests).
|
|
42
|
+
- `extractText` is called from `agent-runner.ts`, `message-formatters.ts`, and internally within `buildParentContext`.
|
|
43
|
+
|
|
44
|
+
### Existing tests
|
|
45
|
+
|
|
46
|
+
There are no direct unit tests for `context.ts`.
|
|
47
|
+
`parent-snapshot.test.ts` mocks `buildParentContext` entirely, so the formatting logic is currently untested.
|
|
48
|
+
|
|
49
|
+
## Design Overview
|
|
50
|
+
|
|
51
|
+
### Extracted formatters
|
|
52
|
+
|
|
53
|
+
Each formatter takes a typed entry and returns `string | undefined` (undefined when the entry should be skipped):
|
|
54
|
+
|
|
55
|
+
```typescript
|
|
56
|
+
function formatMessageEntry(entry: MessageEntry): string | undefined {
|
|
57
|
+
const msg = entry.message;
|
|
58
|
+
const text =
|
|
59
|
+
typeof msg.content === "string"
|
|
60
|
+
? msg.content
|
|
61
|
+
: extractText(msg.content);
|
|
62
|
+
if (!text.trim()) return undefined;
|
|
63
|
+
if (msg.role === "user") return `[User]: ${text.trim()}`;
|
|
64
|
+
if (msg.role === "assistant") return `[Assistant]: ${text.trim()}`;
|
|
65
|
+
return undefined; // skip toolResult and other roles
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function formatCompactionEntry(entry: CompactionEntry): string | undefined {
|
|
69
|
+
return entry.summary ? `[Summary]: ${entry.summary}` : undefined;
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### Simplified orchestrator
|
|
74
|
+
|
|
75
|
+
```typescript
|
|
76
|
+
export function buildParentContext(ctx: SessionContext): string {
|
|
77
|
+
const entries = ctx.sessionManager.getBranch();
|
|
78
|
+
if (!entries || entries.length === 0) return "";
|
|
79
|
+
|
|
80
|
+
const parts = (entries as BranchEntry[])
|
|
81
|
+
.map(formatBranchEntry)
|
|
82
|
+
.filter((p): p is string => p !== undefined);
|
|
83
|
+
|
|
84
|
+
if (parts.length === 0) return "";
|
|
85
|
+
|
|
86
|
+
return `# Parent Conversation Context
|
|
87
|
+
The following is the conversation history from the parent session that spawned you.
|
|
88
|
+
Use this context to understand what has been discussed and decided so far.
|
|
89
|
+
|
|
90
|
+
${parts.join("\n\n")}
|
|
91
|
+
|
|
92
|
+
---
|
|
93
|
+
# Your Task (below)
|
|
94
|
+
`;
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
A thin dispatcher (`formatBranchEntry`) routes by `type`:
|
|
99
|
+
|
|
100
|
+
```typescript
|
|
101
|
+
function formatBranchEntry(entry: BranchEntry): string | undefined {
|
|
102
|
+
if (entry.type === "message") return formatMessageEntry(entry as MessageEntry);
|
|
103
|
+
if (entry.type === "compaction") return formatCompactionEntry(entry as CompactionEntry);
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### Complexity analysis
|
|
109
|
+
|
|
110
|
+
- `formatMessageEntry`: 3 branches (string-vs-array, empty check, role) — estimated cognitive complexity ~4.
|
|
111
|
+
- `formatCompactionEntry`: 1 branch — estimated cognitive complexity ~1.
|
|
112
|
+
- `formatBranchEntry`: 2 branches — estimated cognitive complexity ~2.
|
|
113
|
+
- `buildParentContext`: 2 branches (empty entries, empty parts) — estimated cognitive complexity ~3.
|
|
114
|
+
|
|
115
|
+
All well under the < 10 target.
|
|
116
|
+
|
|
117
|
+
## Module-Level Changes
|
|
118
|
+
|
|
119
|
+
### `src/session/context.ts`
|
|
120
|
+
|
|
121
|
+
1. Add `formatMessageEntry(entry: MessageEntry): string | undefined` — private helper.
|
|
122
|
+
2. Add `formatCompactionEntry(entry: CompactionEntry): string | undefined` — private helper.
|
|
123
|
+
3. Add `formatBranchEntry(entry: BranchEntry): string | undefined` — private dispatcher.
|
|
124
|
+
4. Simplify `buildParentContext` body to use `map(formatBranchEntry).filter(...)`.
|
|
125
|
+
5. No changes to exports — `buildParentContext` and `extractText` signatures are unchanged.
|
|
126
|
+
6. No changes to local types (`MessageEntry`, `CompactionEntry`, `BranchEntry`) or `isTextContent`.
|
|
127
|
+
|
|
128
|
+
### `test/session/context.test.ts` (new)
|
|
129
|
+
|
|
130
|
+
Unit tests for:
|
|
131
|
+
|
|
132
|
+
- `extractText` — string extraction from mixed content arrays.
|
|
133
|
+
- `buildParentContext` — end-to-end formatting with user, assistant, compaction, and skipped entries.
|
|
134
|
+
|
|
135
|
+
The formatters are private, so they are tested indirectly through `buildParentContext`.
|
|
136
|
+
|
|
137
|
+
## Test Impact Analysis
|
|
138
|
+
|
|
139
|
+
1. The new `context.test.ts` enables direct testing of formatting logic that was previously untested (mocked away in `parent-snapshot.test.ts`).
|
|
140
|
+
2. No existing tests become redundant — `parent-snapshot.test.ts` tests snapshot assembly, not formatting.
|
|
141
|
+
3. No existing tests need modification — the public API is unchanged.
|
|
142
|
+
|
|
143
|
+
## TDD Order
|
|
144
|
+
|
|
145
|
+
1. **Red → Green:** Add `test/session/context.test.ts` with tests for `extractText` — empty array, text-only, mixed content types, no text content.
|
|
146
|
+
Commit: `test: add extractText unit tests (#215)`
|
|
147
|
+
|
|
148
|
+
2. **Red → Green:** Add tests for `buildParentContext` — empty branch, user messages, assistant messages, compaction entries with/without summary, mixed entry types, entries with empty text (skipped), non-message/non-compaction entries (skipped), string vs array content.
|
|
149
|
+
Commit: `test: add buildParentContext unit tests (#215)`
|
|
150
|
+
|
|
151
|
+
3. **Refactor:** Extract `formatMessageEntry`, `formatCompactionEntry`, and `formatBranchEntry` from `buildParentContext`.
|
|
152
|
+
Simplify `buildParentContext` to map/filter/join.
|
|
153
|
+
All tests from steps 1–2 must still pass.
|
|
154
|
+
Commit: `refactor: decompose buildParentContext into per-entry formatters (#215)`
|
|
155
|
+
|
|
156
|
+
## Risks and Mitigations
|
|
157
|
+
|
|
158
|
+
| Risk | Mitigation |
|
|
159
|
+
| -------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- |
|
|
160
|
+
| Behavioral regression in formatting | Steps 1–2 lock in current behavior with tests before refactoring |
|
|
161
|
+
| Extracted helpers expose implementation details | Helpers are private (not exported); tested indirectly via public API |
|
|
162
|
+
| `eslint-disable` comment for `no-unnecessary-condition` on `getBranch()` check may need adjustment | Preserve the comment — runtime nullability is documented |
|
|
163
|
+
|
|
164
|
+
## Open Questions
|
|
165
|
+
|
|
166
|
+
None — the decomposition target and strategy are specified by the architecture roadmap.
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
---
|
|
2
|
+
issue: 216
|
|
3
|
+
issue_title: "Decompose startAgent in agent-manager.ts (Phase 13, Step 3)"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Decompose `startAgent` via `RunHandle` lifecycle object
|
|
7
|
+
|
|
8
|
+
## Problem Statement
|
|
9
|
+
|
|
10
|
+
`startAgent` in `agent-manager.ts` is a ~125-line method whose complexity comes not from length alone but from **mutable closure state shared across callbacks**.
|
|
11
|
+
Two `let` variables (`unsubRecordObserver`, `detachParentSignal`) are written in one closure (`onSessionCreated` / setup block) and read in two others (`.then()` / `.catch()`).
|
|
12
|
+
The `.then()` and `.catch()` handlers duplicate finalization logic (observer unsubscription, signal detach, worktree cleanup, background counter management).
|
|
13
|
+
|
|
14
|
+
The original issue proposed extracting three methods (`handleRunCompletion`, `handleRunError`, `finalizeBackgroundRun`).
|
|
15
|
+
This plan replaces that mechanical extraction with a structural fix: introduce a **`RunHandle` lifecycle object** that owns the per-run cleanup state, eliminating the mutable closures and the duplicated finalization.
|
|
16
|
+
|
|
17
|
+
## Goals
|
|
18
|
+
|
|
19
|
+
- Eliminate mutable closure state from `startAgent` — all per-run state lives on `RunHandle`.
|
|
20
|
+
- Eliminate duplicated cleanup/finalization logic in `.then()`/`.catch()` via Tell-Don't-Ask on `RunHandle`.
|
|
21
|
+
- Teach `WorktreeState` to self-clean via `performCleanup()`, removing the ask-tell dance from callers.
|
|
22
|
+
- Reduce `startAgent` to a coordinator (~35–40 lines) with zero mutable `let` bindings.
|
|
23
|
+
- Keep all 929 lines of existing `agent-manager.test.ts` passing unchanged.
|
|
24
|
+
|
|
25
|
+
## Non-Goals
|
|
26
|
+
|
|
27
|
+
- Extracting `RunHandle` to a separate file — it stays private in `agent-manager.ts` for now.
|
|
28
|
+
- Changing the `runner.run()` options shape or the `RunResult` type.
|
|
29
|
+
- Reducing `agent-manager.test.ts` duplication (tracked in #219).
|
|
30
|
+
- Moving `pendingSteers` state to a different owner (the timing gap between `spawn()` and `startAgent()` makes this non-trivial).
|
|
31
|
+
|
|
32
|
+
## Background
|
|
33
|
+
|
|
34
|
+
### Closure tangle in `startAgent`
|
|
35
|
+
|
|
36
|
+
```text
|
|
37
|
+
unsubRecordObserver ──written in──▶ onSessionCreated callback
|
|
38
|
+
──read in────▶ .then() handler
|
|
39
|
+
──read in────▶ .catch() handler
|
|
40
|
+
|
|
41
|
+
detachParentSignal ──written in──▶ setup block
|
|
42
|
+
──read via───▶ detach closure
|
|
43
|
+
──read in────▶ .then() handler (via detach)
|
|
44
|
+
──read in────▶ .catch() handler (via detach)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Both variables are resource-release handles — acquired at different times, released in the same place.
|
|
48
|
+
They have no owner; they float as mutable `let` bindings shared across closures.
|
|
49
|
+
|
|
50
|
+
### Duplicated finalization
|
|
51
|
+
|
|
52
|
+
Both `.then()` and `.catch()` perform:
|
|
53
|
+
|
|
54
|
+
1. `unsubRecordObserver?.(); detach();` — release listeners
|
|
55
|
+
2. Worktree cleanup via `this.worktrees.cleanup()` + `record.worktreeState.recordCleanup()` — ask-tell dance
|
|
56
|
+
3. Background finalization: `this.runningBackground--`, `this.observer?.onAgentCompleted(record)`, `this.drainQueue()`
|
|
57
|
+
|
|
58
|
+
### Existing types
|
|
59
|
+
|
|
60
|
+
- `RunResult` is already exported from `agent-runner.ts` — `RunHandle.complete()` can accept it directly.
|
|
61
|
+
- `WorktreeManager.cleanup()` accepts `WorktreeInfo`, which `WorktreeState` satisfies structurally (has `path` and `branch`).
|
|
62
|
+
- `record.description` is available on `AgentRecord` at cleanup time, so `RunHandle` doesn't need a separate `description` parameter.
|
|
63
|
+
|
|
64
|
+
## Design Overview
|
|
65
|
+
|
|
66
|
+
### `WorktreeState.performCleanup(worktrees, description)`
|
|
67
|
+
|
|
68
|
+
Teach `WorktreeState` to orchestrate its own cleanup instead of requiring callers to do the ask-tell dance:
|
|
69
|
+
|
|
70
|
+
```typescript
|
|
71
|
+
performCleanup(worktrees: WorktreeManager, description: string): WorktreeCleanupResult {
|
|
72
|
+
const result = worktrees.cleanup(this, description);
|
|
73
|
+
this._cleanupResult = result;
|
|
74
|
+
return result;
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
This replaces the two-step pattern at both call sites:
|
|
79
|
+
|
|
80
|
+
```typescript
|
|
81
|
+
// Before (caller orchestrates):
|
|
82
|
+
const wtResult = this.worktrees.cleanup(record.worktreeState, options.description);
|
|
83
|
+
record.worktreeState.recordCleanup(wtResult);
|
|
84
|
+
|
|
85
|
+
// After (Tell-Don't-Ask):
|
|
86
|
+
const wtResult = record.worktreeState.performCleanup(this.worktrees, record.description);
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### `RunHandle` lifecycle object
|
|
90
|
+
|
|
91
|
+
A short-lived object born when a run starts, consumed when it ends.
|
|
92
|
+
Owns the two resource-release handles and exposes `complete()`/`fail()` as the only way to finish a run.
|
|
93
|
+
|
|
94
|
+
```typescript
|
|
95
|
+
class RunHandle {
|
|
96
|
+
private unsub?: () => void;
|
|
97
|
+
private detach?: () => void;
|
|
98
|
+
private onFinished?: () => void;
|
|
99
|
+
|
|
100
|
+
constructor(
|
|
101
|
+
private readonly record: AgentRecord,
|
|
102
|
+
private readonly worktrees: WorktreeManager,
|
|
103
|
+
onFinished?: () => void,
|
|
104
|
+
) { this.onFinished = onFinished; }
|
|
105
|
+
|
|
106
|
+
wireSignal(signal: AbortSignal | undefined, onAbort: () => void): void;
|
|
107
|
+
attachObserver(unsub: () => void): void;
|
|
108
|
+
complete(result: RunResult): string;
|
|
109
|
+
fail(err: unknown): void;
|
|
110
|
+
|
|
111
|
+
private detachListeners(): void;
|
|
112
|
+
private fireOnFinished(): void; // idempotent — nulls callback after first call
|
|
113
|
+
}
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Key design decisions:
|
|
117
|
+
|
|
118
|
+
1. **`onFinished` callback** — set once at construction, fires at most once (idempotent guard).
|
|
119
|
+
For background agents this is `() => this.finalizeBackgroundRun(record)`.
|
|
120
|
+
For foreground agents it is `undefined`.
|
|
121
|
+
This eliminates the `if (options.isBackground)` check from both `.then()` and `.catch()`.
|
|
122
|
+
|
|
123
|
+
2. **`fireOnFinished` is idempotent** — if `complete()` throws (e.g., worktree cleanup fails on the success path) and the promise chain falls through to `.catch()` → `fail()`, the callback fires exactly once.
|
|
124
|
+
`AgentRecord`'s transition guards (`if (this._status !== "stopped")`) protect against double state transitions.
|
|
125
|
+
|
|
126
|
+
3. **`complete()` returns `result.responseText`** — the branch-suffix text is stored on the record via `markCompleted(finalResult)` but the promise resolves with the original response text, matching current behavior.
|
|
127
|
+
|
|
128
|
+
4. **No `worktrees` or `description` parameters on `complete()`/`fail()`** — `RunHandle` gets `worktrees` at construction; `description` comes from `record.description`.
|
|
129
|
+
|
|
130
|
+
### `finalizeBackgroundRun(record)` on `AgentManager`
|
|
131
|
+
|
|
132
|
+
Extracts the shared background finalization:
|
|
133
|
+
|
|
134
|
+
```typescript
|
|
135
|
+
private finalizeBackgroundRun(record: AgentRecord): void {
|
|
136
|
+
this.runningBackground--;
|
|
137
|
+
try { this.observer?.onAgentCompleted(record); }
|
|
138
|
+
catch (err) { debugLog("onAgentCompleted observer", err); }
|
|
139
|
+
this.drainQueue();
|
|
140
|
+
}
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Note: the current `.catch()` handler does not wrap `onAgentCompleted` in try/catch, but `.then()` does.
|
|
144
|
+
The extracted method always wraps it — an observer error must never prevent `drainQueue()` from running.
|
|
145
|
+
|
|
146
|
+
### Small helpers on `AgentManager`
|
|
147
|
+
|
|
148
|
+
Two additional extractions to keep `startAgent` focused:
|
|
149
|
+
|
|
150
|
+
```typescript
|
|
151
|
+
private setupWorktree(
|
|
152
|
+
id: string, record: AgentRecord, isolation: IsolationMode | undefined,
|
|
153
|
+
): string | undefined;
|
|
154
|
+
|
|
155
|
+
private flushPendingSteers(id: string, session: AgentSession): void;
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
### Resulting `startAgent` shape
|
|
159
|
+
|
|
160
|
+
After all extractions, `startAgent` becomes a coordinator with **zero mutable `let` bindings**:
|
|
161
|
+
|
|
162
|
+
```typescript
|
|
163
|
+
private startAgent(id: string, record: AgentRecord, { snapshot, type, prompt, options }: SpawnArgs) {
|
|
164
|
+
const worktreeCwd = this.setupWorktree(id, record, options.isolation);
|
|
165
|
+
|
|
166
|
+
record.markRunning(Date.now());
|
|
167
|
+
if (options.isBackground) this.runningBackground++;
|
|
168
|
+
this.observer?.onAgentStarted(record);
|
|
169
|
+
|
|
170
|
+
const handle = new RunHandle(
|
|
171
|
+
record, this.worktrees,
|
|
172
|
+
options.isBackground ? () => this.finalizeBackgroundRun(record) : undefined,
|
|
173
|
+
);
|
|
174
|
+
handle.wireSignal(options.signal, () => this.abort(id));
|
|
175
|
+
|
|
176
|
+
const runConfig = this.getRunConfig?.();
|
|
177
|
+
record.promise = this.runner.run(snapshot, type, prompt, {
|
|
178
|
+
context: { exec: this.exec, registry: this.registry, cwd: worktreeCwd, parentSession: options.parentSession },
|
|
179
|
+
model: options.model, maxTurns: options.maxTurns,
|
|
180
|
+
defaultMaxTurns: runConfig?.defaultMaxTurns, graceTurns: runConfig?.graceTurns,
|
|
181
|
+
isolated: options.isolated, thinkingLevel: options.thinkingLevel,
|
|
182
|
+
signal: record.abortController!.signal,
|
|
183
|
+
onSessionCreated: (session) => {
|
|
184
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
|
185
|
+
const outputFile = session.sessionManager?.getSessionFile?.() ?? undefined;
|
|
186
|
+
record.execution = { session, outputFile };
|
|
187
|
+
this.flushPendingSteers(id, session);
|
|
188
|
+
handle.attachObserver(subscribeRecordObserver(session, record, {
|
|
189
|
+
onCompact: (r, info) => this.observer?.onAgentCompacted(r, info),
|
|
190
|
+
}));
|
|
191
|
+
options.onSessionCreated?.(session, record);
|
|
192
|
+
},
|
|
193
|
+
})
|
|
194
|
+
.then((result) => handle.complete(result))
|
|
195
|
+
.catch((err: unknown) => { handle.fail(err); return ""; });
|
|
196
|
+
}
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
The `.then()` and `.catch()` are one-liners.
|
|
200
|
+
The `onSessionCreated` callback captures only `const` references (no mutable closure state).
|
|
201
|
+
The `record.promise` assignment moves inline (no intermediate `const promise`).
|
|
202
|
+
|
|
203
|
+
## Module-Level Changes
|
|
204
|
+
|
|
205
|
+
| File | Change |
|
|
206
|
+
| --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
207
|
+
| `src/lifecycle/worktree-state.ts` | Add `performCleanup(worktrees, description)` method |
|
|
208
|
+
| `src/lifecycle/agent-manager.ts` | Add `RunHandle` class (private); add `finalizeBackgroundRun()`, `setupWorktree()`, `flushPendingSteers()` methods; rewrite `startAgent` to use them |
|
|
209
|
+
| `test/lifecycle/worktree-state.test.ts` | Add tests for `performCleanup` |
|
|
210
|
+
|
|
211
|
+
## Test Impact Analysis
|
|
212
|
+
|
|
213
|
+
1. **New unit tests**: `WorktreeState.performCleanup` — directly testable with a mock `WorktreeManager`.
|
|
214
|
+
`RunHandle` is tested indirectly through the existing `agent-manager.test.ts` suite (929 lines, comprehensive coverage of success/error/worktree/signal/background paths).
|
|
215
|
+
2. **Redundant tests**: None — all existing tests exercise the same public API (`spawn`, `spawnAndWait`, `abort`, `resume`).
|
|
216
|
+
3. **Tests that must stay as-is**: All of `agent-manager.test.ts` — the refactoring is behavior-preserving and these tests verify every path through `RunHandle.complete()` and `RunHandle.fail()`.
|
|
217
|
+
|
|
218
|
+
## TDD Order
|
|
219
|
+
|
|
220
|
+
1. **`WorktreeState.performCleanup`** — red: test that `performCleanup` calls the manager, records the result, and returns it.
|
|
221
|
+
Green: implement `performCleanup` on `WorktreeState`.
|
|
222
|
+
Commit: `feat: add WorktreeState.performCleanup for self-cleanup (#216)`
|
|
223
|
+
|
|
224
|
+
2. **Use `performCleanup` in `startAgent`** — refactor both cleanup sites in `.then()` and `.catch()` to use `record.worktreeState.performCleanup()`.
|
|
225
|
+
Verify: all existing agent-manager tests pass.
|
|
226
|
+
Commit: `refactor: use WorktreeState.performCleanup in startAgent (#216)`
|
|
227
|
+
|
|
228
|
+
3. **Extract `finalizeBackgroundRun`** — extract the shared background finalization block.
|
|
229
|
+
Add try/catch around `onAgentCompleted` (unifying the asymmetry between `.then()` and `.catch()`).
|
|
230
|
+
Verify: all existing agent-manager tests pass.
|
|
231
|
+
Commit: `refactor: extract finalizeBackgroundRun from startAgent (#216)`
|
|
232
|
+
|
|
233
|
+
4. **Introduce `RunHandle` and rewire `startAgent`** — add `RunHandle` class with `wireSignal`, `attachObserver`, `complete`, `fail`, `detachListeners`, `fireOnFinished`.
|
|
234
|
+
Extract `setupWorktree` and `flushPendingSteers`.
|
|
235
|
+
Rewrite `startAgent` to use `RunHandle`, eliminating all mutable `let` bindings.
|
|
236
|
+
Verify: all existing agent-manager tests pass.
|
|
237
|
+
Run `pnpm run check` to verify types.
|
|
238
|
+
Commit: `refactor: introduce RunHandle lifecycle object in startAgent (#216)`
|
|
239
|
+
|
|
240
|
+
## Risks and Mitigations
|
|
241
|
+
|
|
242
|
+
1. **`complete()` throws after `fireOnFinished`** — if worktree cleanup succeeds, state transition succeeds, but `fireOnFinished` itself throws (observer error), the `.catch()` handler calls `fail()` which calls `fireOnFinished` again.
|
|
243
|
+
Mitigation: `fireOnFinished` is idempotent (nulls callback after first call), and `finalizeBackgroundRun` wraps `onAgentCompleted` in try/catch.
|
|
244
|
+
`AgentRecord` transition guards prevent double state transitions.
|
|
245
|
+
|
|
246
|
+
2. **`complete()` throws before state transition** — e.g., `worktrees.cleanup()` throws on the success path.
|
|
247
|
+
The `.catch()` handler calls `fail()`, which marks the record as error and does best-effort worktree cleanup.
|
|
248
|
+
This matches current behavior (the success-path worktree cleanup is not wrapped in try/catch today).
|
|
249
|
+
|
|
250
|
+
3. **Subtle behavior change in error-path observer notification** — current `.catch()` does not wrap `onAgentCompleted` in try/catch; `finalizeBackgroundRun` does.
|
|
251
|
+
This is a minor hardening, not a behavior change — an observer throwing during error finalization would previously have prevented `drainQueue()` from running.
|
|
252
|
+
|
|
253
|
+
## Open Questions
|
|
254
|
+
|
|
255
|
+
- None — the design is straightforward and all decisions are driven by eliminating the identified smells.
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
---
|
|
2
|
+
issue: 215
|
|
3
|
+
issue_title: "Decompose buildParentContext (cognitive 30) (Phase 13, Step 2)"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Retro: #215 — Decompose buildParentContext
|
|
7
|
+
|
|
8
|
+
## Stage: Planning (2026-05-25T12:00:00Z)
|
|
9
|
+
|
|
10
|
+
### Session summary
|
|
11
|
+
|
|
12
|
+
Produced a 3-step TDD plan to decompose `buildParentContext` in `src/session/context.ts`.
|
|
13
|
+
Steps 1–2 add tests locking current behavior for `extractText` and `buildParentContext`; step 3 extracts three private helpers (`formatMessageEntry`, `formatCompactionEntry`, `formatBranchEntry`) and simplifies the orchestrator to map/filter/join.
|
|
14
|
+
|
|
15
|
+
### Observations
|
|
16
|
+
|
|
17
|
+
- No existing unit tests cover `context.ts` — `parent-snapshot.test.ts` mocks `buildParentContext` entirely, so the formatting logic is currently untested.
|
|
18
|
+
- The decomposition is straightforward with no design ambiguity; the architecture roadmap specifies the exact extraction targets.
|
|
19
|
+
- All extracted helpers remain private (not exported), keeping the public API surface unchanged.
|
|
20
|
+
- The `eslint-disable` comment on the `getBranch()` nullability check must be preserved through the refactoring step.
|
|
21
|
+
|
|
22
|
+
## Stage: Implementation — TDD (2026-05-25T22:36:00Z)
|
|
23
|
+
|
|
24
|
+
### Session summary
|
|
25
|
+
|
|
26
|
+
Completed all 3 TDD steps: 2 test-only commits locking `extractText` (5 tests) and `buildParentContext` (14 tests) behavior, then a refactor commit extracting `formatMessageEntry`, `formatCompactionEntry`, and `formatBranchEntry`.
|
|
27
|
+
Test count increased from 939 to 958 (+19).
|
|
28
|
+
All checks green: full suite, `pnpm run check`, `pnpm run lint`, `pnpm fallow dead-code`.
|
|
29
|
+
|
|
30
|
+
### Observations
|
|
31
|
+
|
|
32
|
+
- Because `extractText` and `buildParentContext` already existed, both test steps passed immediately (no red phase) — this is correct for behavior-locking tests before a refactor.
|
|
33
|
+
- The `makeCtx` helper in the test file creates a minimal `SessionContext` satisfying only `sessionManager.getBranch()`; the extra required fields (`cwd`, `model`, `modelRegistry`, `getSystemPrompt`) are satisfied with stubs.
|
|
34
|
+
- The `eslint-disable` comment on the `getBranch()` nullability check was preserved unchanged through the refactor.
|
|
35
|
+
- No deviations from the plan.
|
|
36
|
+
|
|
37
|
+
## Stage: Final Retrospective (2026-05-26T02:50:00Z)
|
|
38
|
+
|
|
39
|
+
### Session summary
|
|
40
|
+
|
|
41
|
+
Completed the full issue lifecycle (plan → TDD → ship → retro) in a single session with zero rework or user corrections.
|
|
42
|
+
Released as `pi-subagents-v7.5.1`.
|
|
43
|
+
Test count: 939 → 958 (+19 tests in new `test/session/context.test.ts`).
|
|
44
|
+
|
|
45
|
+
### Observations
|
|
46
|
+
|
|
47
|
+
#### What went well
|
|
48
|
+
|
|
49
|
+
- Zero-deviation execution: the architecture roadmap specified exact decomposition targets, the plan translated them into 3 TDD steps, and implementation was a straight transcription.
|
|
50
|
+
- Multi-model cost efficiency: `claude-sonnet-4-6` for planning/TDD, `deepseek-v4-flash` for shipping (~$0.002 for the entire ship workflow), `claude-opus-4-6` for retro synthesis.
|
|
51
|
+
- Incremental verification at every stage: per-file test runs after each TDD step, full suite + `pnpm run check` + `pnpm run lint` + `pnpm fallow dead-code` after the last step, repo-root lint before push.
|
|
52
|
+
|
|
53
|
+
#### What caused friction (agent side)
|
|
54
|
+
|
|
55
|
+
None identified.
|
|
56
|
+
The issue was well-scoped, the architecture roadmap was unambiguous, and the existing code had no surprising edge cases.
|
|
57
|
+
|
|
58
|
+
#### What caused friction (user side)
|
|
59
|
+
|
|
60
|
+
None identified.
|
|
61
|
+
The user ran four prompt commands in sequence (`/plan-issue`, `/tdd-plan`, `/ship-issue`, `/retro`) with no corrections or redirections needed.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
---
|
|
2
|
+
issue: 216
|
|
3
|
+
issue_title: "Decompose startAgent in agent-manager.ts (Phase 13, Step 3)"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Retro: #216 — Decompose startAgent in agent-manager.ts
|
|
7
|
+
|
|
8
|
+
## Stage: Planning (2026-05-25T20:00:00Z)
|
|
9
|
+
|
|
10
|
+
### Session summary
|
|
11
|
+
|
|
12
|
+
Analyzed the `startAgent` method's structural problems beyond surface-level length.
|
|
13
|
+
The original issue proposed extracting three methods (`handleRunCompletion`, `handleRunError`, `finalizeBackgroundRun`).
|
|
14
|
+
Through design discussion, identified the root cause as **mutable closure state without an owner** — two `let` variables shared across three closures — and proposed a `RunHandle` lifecycle object as the missing collaborator.
|
|
15
|
+
|
|
16
|
+
### Observations
|
|
17
|
+
|
|
18
|
+
- The initial mechanical-extraction approach (3 methods) wouldn't have eliminated the mutable closure variables — `.then()`/`.catch()` would still close over `unsubRecordObserver` and `detach`.
|
|
19
|
+
`RunHandle` eliminates these entirely by owning the resource-release handles.
|
|
20
|
+
- `WorktreeState` has an ask-tell smell: callers call `worktrees.cleanup()` then `worktreeState.recordCleanup()`.
|
|
21
|
+
Adding `performCleanup()` is a small prep step that simplifies `RunHandle`'s completion/error methods.
|
|
22
|
+
- `record.description` is already available on `AgentRecord`, so `RunHandle` doesn't need `description` as a separate dependency — it can use `record.description` for worktree cleanup.
|
|
23
|
+
- `RunResult` is already exported from `agent-runner.ts`, so `RunHandle.complete()` can accept it directly without a new type.
|
|
24
|
+
- The `.catch()` handler doesn't wrap `onAgentCompleted` in try/catch while `.then()` does — `finalizeBackgroundRun` unifies this by always wrapping, preventing an observer error from blocking `drainQueue()`.
|
|
25
|
+
- `fireOnFinished` idempotency is important: if `complete()` throws after worktree cleanup but before returning, `.catch()` → `fail()` must not double-fire the background finalization.
|
|
26
|
+
`AgentRecord`'s transition guards (`if (this._status !== "stopped")`) provide a second safety net.
|
|
27
|
+
|
|
28
|
+
## Stage: Implementation — TDD (2026-05-25T23:20:00Z)
|
|
29
|
+
|
|
30
|
+
### Session summary
|
|
31
|
+
|
|
32
|
+
Completed all 4 TDD steps across 5 commits (one extra for the type-annotation fixup caught by `pnpm run check`).
|
|
33
|
+
Added 4 new tests for `WorktreeState.performCleanup`; total test count rose from 958 to 962.
|
|
34
|
+
All 60 test files pass; `pnpm run check`, `pnpm run lint`, and `pnpm fallow dead-code` all clean.
|
|
35
|
+
|
|
36
|
+
### Observations
|
|
37
|
+
|
|
38
|
+
- One deviation from the plan: the `makeWorktrees` test helper in `worktree-state.test.ts` needed an explicit `WorktreeCleanupResult` type annotation on its `result` parameter — TypeScript inferred `{ hasChanges: boolean }` (no optional `branch`/`path` fields) from the default argument, which caused a type error on the call site that passed `{ hasChanges: true, branch: "pi-agent-1" }`.
|
|
39
|
+
Fixed in the same commit as the `RunHandle` step.
|
|
40
|
+
- `RunHandle` landed exactly as designed: `wireSignal`, `attachObserver`, `complete`, `fail`, `releaseListeners`, `fireOnFinished` (idempotent). `startAgent` is now ~40 lines with zero mutable `let` bindings and one-liner `.then()`/`.catch()` handlers.
|
|
41
|
+
- `flushPendingSteers` and `setupWorktree` extracted cleanly — each about 8 lines, no surprises.
|
|
42
|
+
- The `WorktreeCleanupResult` import needed to be added to the test file alongside the existing `WorktreeManager` import for the type annotation fix — minor but worth noting for the next engineer.
|
|
43
|
+
- Architecture doc updated: Step 3 entry now reflects `RunHandle` rather than the original `handleRunCompletion`/`handleRunError` proposal.
|
package/package.json
CHANGED
|
@@ -12,7 +12,7 @@ import type { AgentSession } from "@earendil-works/pi-coding-agent";
|
|
|
12
12
|
import { AgentTypeRegistry } from "#src/config/agent-types";
|
|
13
13
|
import { debugLog } from "#src/debug";
|
|
14
14
|
import { AgentRecord } from "#src/lifecycle/agent-record";
|
|
15
|
-
import type { AgentRunner } from "#src/lifecycle/agent-runner";
|
|
15
|
+
import type { AgentRunner, RunResult } from "#src/lifecycle/agent-runner";
|
|
16
16
|
import type { ParentSnapshot } from "#src/lifecycle/parent-snapshot";
|
|
17
17
|
import type { WorktreeManager } from "#src/lifecycle/worktree";
|
|
18
18
|
import { WorktreeState } from "#src/lifecycle/worktree-state";
|
|
@@ -21,6 +21,95 @@ import { subscribeRecordObserver } from "#src/observation/record-observer";
|
|
|
21
21
|
import type { RunConfig } from "#src/runtime";
|
|
22
22
|
import type { AgentInvocation, IsolationMode, ShellExec, SubagentType, ThinkingLevel } from "#src/types";
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* RunHandle — per-run lifecycle object that owns cleanup state.
|
|
26
|
+
*
|
|
27
|
+
* Owns the observer unsubscribe and parent-signal detach handles acquired during
|
|
28
|
+
* a run. Exposes `complete()` and `fail()` as the only way to finish a run,
|
|
29
|
+
* eliminating mutable closure variables from `startAgent`.
|
|
30
|
+
* `fireOnFinished` is idempotent — safe to call from both success and error paths.
|
|
31
|
+
*/
|
|
32
|
+
class RunHandle {
|
|
33
|
+
private unsub?: () => void;
|
|
34
|
+
private detachFn?: () => void;
|
|
35
|
+
private onFinished?: () => void;
|
|
36
|
+
|
|
37
|
+
constructor(
|
|
38
|
+
private readonly record: AgentRecord,
|
|
39
|
+
private readonly worktrees: WorktreeManager,
|
|
40
|
+
onFinished?: () => void,
|
|
41
|
+
) {
|
|
42
|
+
this.onFinished = onFinished;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Wire a parent AbortSignal so it stops this agent when fired. */
|
|
46
|
+
wireSignal(signal: AbortSignal | undefined, onAbort: () => void): void {
|
|
47
|
+
if (!signal) return;
|
|
48
|
+
const listener = () => onAbort();
|
|
49
|
+
signal.addEventListener("abort", listener, { once: true });
|
|
50
|
+
this.detachFn = () => signal.removeEventListener("abort", listener);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Store the record-observer unsubscribe handle (called from onSessionCreated). */
|
|
54
|
+
attachObserver(unsub: () => void): void {
|
|
55
|
+
this.unsub = unsub;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Complete a run successfully — clean up, transition record, fire onFinished. */
|
|
59
|
+
complete(result: RunResult): string {
|
|
60
|
+
this.releaseListeners();
|
|
61
|
+
|
|
62
|
+
let finalResult = result.responseText;
|
|
63
|
+
if (this.record.worktreeState) {
|
|
64
|
+
const wtResult = this.record.worktreeState.performCleanup(this.worktrees, this.record.description);
|
|
65
|
+
if (wtResult.hasChanges && wtResult.branch) {
|
|
66
|
+
finalResult += `\n\n---\nChanges saved to branch \`${wtResult.branch}\`. Merge with: \`git merge ${wtResult.branch}\``;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (result.aborted) this.record.markAborted(finalResult);
|
|
71
|
+
else if (result.steered) this.record.markSteered(finalResult);
|
|
72
|
+
else this.record.markCompleted(finalResult);
|
|
73
|
+
|
|
74
|
+
// Update execution with the final session/outputFile from the runner
|
|
75
|
+
this.record.execution = {
|
|
76
|
+
session: result.session,
|
|
77
|
+
outputFile: result.sessionFile ?? this.record.execution?.outputFile,
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
this.fireOnFinished();
|
|
81
|
+
return result.responseText;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Fail a run — mark error, best-effort worktree cleanup, fire onFinished. */
|
|
85
|
+
fail(err: unknown): void {
|
|
86
|
+
this.record.markError(err);
|
|
87
|
+
this.releaseListeners();
|
|
88
|
+
|
|
89
|
+
if (this.record.worktreeState) {
|
|
90
|
+
try {
|
|
91
|
+
this.record.worktreeState.performCleanup(this.worktrees, this.record.description);
|
|
92
|
+
} catch (cleanupErr) { debugLog("cleanupWorktree on agent error", cleanupErr); }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
this.fireOnFinished();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private releaseListeners(): void {
|
|
99
|
+
this.unsub?.();
|
|
100
|
+
this.unsub = undefined;
|
|
101
|
+
this.detachFn?.();
|
|
102
|
+
this.detachFn = undefined;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Fire the onFinished callback at most once. */
|
|
106
|
+
private fireOnFinished(): void {
|
|
107
|
+
const fn = this.onFinished;
|
|
108
|
+
this.onFinished = undefined;
|
|
109
|
+
fn?.();
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
24
113
|
export type CompactionInfo = { reason: "manual" | "threshold" | "overflow"; tokensBefore: number };
|
|
25
114
|
|
|
26
115
|
/** Observer interface for agent lifecycle notifications. */
|
|
@@ -192,39 +281,20 @@ export class AgentManager {
|
|
|
192
281
|
|
|
193
282
|
/** Actually start an agent (called immediately or from queue drain). */
|
|
194
283
|
private startAgent(id: string, record: AgentRecord, { snapshot, type, prompt, options }: SpawnArgs) {
|
|
195
|
-
|
|
196
|
-
// fail loud if not possible (no silent fallback to main tree). Done
|
|
197
|
-
// BEFORE state mutation so a throw doesn't leave the record half-running.
|
|
198
|
-
let worktreeCwd: string | undefined;
|
|
199
|
-
if (options.isolation === "worktree") {
|
|
200
|
-
const wt = this.worktrees.create(id);
|
|
201
|
-
if (!wt) {
|
|
202
|
-
throw new Error(
|
|
203
|
-
'Cannot run with isolation: "worktree" — not a git repo, no commits yet, or `git worktree add` failed. ' +
|
|
204
|
-
'Initialize git and commit at least once, or omit `isolation`.',
|
|
205
|
-
);
|
|
206
|
-
}
|
|
207
|
-
record.worktreeState = new WorktreeState(wt);
|
|
208
|
-
worktreeCwd = wt.path;
|
|
209
|
-
}
|
|
284
|
+
const worktreeCwd = this.setupWorktree(id, record, options.isolation);
|
|
210
285
|
|
|
211
286
|
record.markRunning(Date.now());
|
|
212
287
|
if (options.isBackground) this.runningBackground++;
|
|
213
288
|
this.observer?.onAgentStarted(record);
|
|
214
289
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
detachParentSignal = () => options.signal!.removeEventListener("abort", onParentAbort);
|
|
221
|
-
}
|
|
222
|
-
const detach = () => { detachParentSignal?.(); detachParentSignal = undefined; };
|
|
223
|
-
|
|
224
|
-
let unsubRecordObserver: (() => void) | undefined;
|
|
290
|
+
const handle = new RunHandle(
|
|
291
|
+
record, this.worktrees,
|
|
292
|
+
options.isBackground ? () => this.finalizeBackgroundRun(record) : undefined,
|
|
293
|
+
);
|
|
294
|
+
handle.wireSignal(options.signal, () => this.abort(id));
|
|
225
295
|
|
|
226
296
|
const runConfig = this.getRunConfig?.();
|
|
227
|
-
|
|
297
|
+
record.promise = this.runner.run(snapshot, type, prompt, {
|
|
228
298
|
context: {
|
|
229
299
|
exec: this.exec,
|
|
230
300
|
registry: this.registry,
|
|
@@ -243,76 +313,49 @@ export class AgentManager {
|
|
|
243
313
|
// before the run completes (e.g. in background agent status messages).
|
|
244
314
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- sessionManager is typed as always present but Pi SDK may not provide it
|
|
245
315
|
const outputFile = session.sessionManager?.getSessionFile?.() ?? undefined;
|
|
246
|
-
// Set the execution-state collaborator — born complete at session creation.
|
|
247
316
|
record.execution = { session, outputFile };
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
if (buffered?.length) {
|
|
251
|
-
for (const msg of buffered) {
|
|
252
|
-
session.steer(msg).catch(() => {});
|
|
253
|
-
}
|
|
254
|
-
this.pendingSteers.delete(id);
|
|
255
|
-
}
|
|
256
|
-
// Subscribe record observer for stats accumulation
|
|
257
|
-
unsubRecordObserver = subscribeRecordObserver(session, record, {
|
|
317
|
+
this.flushPendingSteers(id, session);
|
|
318
|
+
handle.attachObserver(subscribeRecordObserver(session, record, {
|
|
258
319
|
onCompact: (r, info) => this.observer?.onAgentCompacted(r, info),
|
|
259
|
-
});
|
|
320
|
+
}));
|
|
260
321
|
options.onSessionCreated?.(session, record);
|
|
261
322
|
},
|
|
262
323
|
})
|
|
263
|
-
.then((
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
// Clean up worktree before transition so the final result includes branch text
|
|
268
|
-
let finalResult = responseText;
|
|
269
|
-
if (record.worktreeState) {
|
|
270
|
-
const wtResult = this.worktrees.cleanup(record.worktreeState, options.description);
|
|
271
|
-
record.worktreeState.recordCleanup(wtResult);
|
|
272
|
-
if (wtResult.hasChanges && wtResult.branch) {
|
|
273
|
-
finalResult += `\n\n---\nChanges saved to branch \`${wtResult.branch}\`. Merge with: \`git merge ${wtResult.branch}\``;
|
|
274
|
-
}
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
// Transition — guards against overwriting externally-stopped status
|
|
278
|
-
if (aborted) record.markAborted(finalResult);
|
|
279
|
-
else if (steered) record.markSteered(finalResult);
|
|
280
|
-
else record.markCompleted(finalResult);
|
|
281
|
-
|
|
282
|
-
// Update execution collaborator with final session/outputFile from runner
|
|
283
|
-
record.execution = { session, outputFile: sessionFile ?? record.execution?.outputFile };
|
|
284
|
-
|
|
285
|
-
if (options.isBackground) {
|
|
286
|
-
this.runningBackground--;
|
|
287
|
-
try { this.observer?.onAgentCompleted(record); } catch (err) { debugLog("onAgentCompleted observer", err); }
|
|
288
|
-
this.drainQueue();
|
|
289
|
-
}
|
|
290
|
-
return responseText;
|
|
291
|
-
})
|
|
292
|
-
.catch((err: unknown) => {
|
|
293
|
-
record.markError(err);
|
|
294
|
-
|
|
295
|
-
unsubRecordObserver?.();
|
|
296
|
-
detach();
|
|
297
|
-
|
|
298
|
-
// Best-effort worktree cleanup on error
|
|
299
|
-
if (record.worktreeState) {
|
|
300
|
-
try {
|
|
301
|
-
const wtResult = this.worktrees.cleanup(record.worktreeState, options.description);
|
|
302
|
-
record.worktreeState.recordCleanup(wtResult);
|
|
324
|
+
.then((result) => handle.complete(result))
|
|
325
|
+
.catch((err: unknown) => { handle.fail(err); return ""; });
|
|
326
|
+
}
|
|
303
327
|
|
|
304
|
-
|
|
305
|
-
|
|
328
|
+
/** Create a worktree for isolated agents. Throws (strict) if isolation is requested but impossible. */
|
|
329
|
+
private setupWorktree(
|
|
330
|
+
id: string, record: AgentRecord, isolation: IsolationMode | undefined,
|
|
331
|
+
): string | undefined {
|
|
332
|
+
if (isolation !== "worktree") return undefined;
|
|
333
|
+
const wt = this.worktrees.create(id);
|
|
334
|
+
if (!wt) {
|
|
335
|
+
throw new Error(
|
|
336
|
+
'Cannot run with isolation: "worktree" — not a git repo, no commits yet, or `git worktree add` failed. ' +
|
|
337
|
+
'Initialize git and commit at least once, or omit `isolation`.',
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
record.worktreeState = new WorktreeState(wt);
|
|
341
|
+
return wt.path;
|
|
342
|
+
}
|
|
306
343
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
344
|
+
/** Flush any steers buffered before the session was ready. */
|
|
345
|
+
private flushPendingSteers(id: string, session: AgentSession): void {
|
|
346
|
+
const buffered = this.pendingSteers.get(id);
|
|
347
|
+
if (!buffered?.length) return;
|
|
348
|
+
for (const msg of buffered) {
|
|
349
|
+
session.steer(msg).catch(() => {});
|
|
350
|
+
}
|
|
351
|
+
this.pendingSteers.delete(id);
|
|
352
|
+
}
|
|
314
353
|
|
|
315
|
-
|
|
354
|
+
/** Decrement background counter, notify observer (crash-safe), and drain the queue. */
|
|
355
|
+
private finalizeBackgroundRun(record: AgentRecord): void {
|
|
356
|
+
this.runningBackground--;
|
|
357
|
+
try { this.observer?.onAgentCompleted(record); } catch (err) { debugLog("onAgentCompleted observer", err); }
|
|
358
|
+
this.drainQueue();
|
|
316
359
|
}
|
|
317
360
|
|
|
318
361
|
/** Start queued agents up to the concurrency limit. */
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* cleanupResult is recorded once at completion or error — it is not set at construction.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import type { WorktreeCleanupResult, WorktreeInfo } from "#src/lifecycle/worktree";
|
|
9
|
+
import type { WorktreeCleanupResult, WorktreeInfo, WorktreeManager } from "#src/lifecycle/worktree";
|
|
10
10
|
|
|
11
11
|
export type { WorktreeCleanupResult, WorktreeInfo };
|
|
12
12
|
|
|
@@ -32,4 +32,14 @@ export class WorktreeState {
|
|
|
32
32
|
recordCleanup(result: WorktreeCleanupResult): void {
|
|
33
33
|
this._cleanupResult = result;
|
|
34
34
|
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Perform worktree cleanup and record the result.
|
|
38
|
+
* Tell-Don't-Ask: callers no longer need to orchestrate cleanup + recordCleanup separately.
|
|
39
|
+
*/
|
|
40
|
+
performCleanup(worktrees: WorktreeManager, description: string): WorktreeCleanupResult {
|
|
41
|
+
const result = worktrees.cleanup(this, description);
|
|
42
|
+
this._cleanupResult = result;
|
|
43
|
+
return result;
|
|
44
|
+
}
|
|
35
45
|
}
|
package/src/session/context.ts
CHANGED
|
@@ -30,6 +30,28 @@ export function extractText(content: unknown[]): string {
|
|
|
30
30
|
.join("\n");
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/** Format a message entry (user/assistant); returns undefined for roles to skip. */
|
|
34
|
+
function formatMessageEntry(entry: MessageEntry): string | undefined {
|
|
35
|
+
const msg = entry.message;
|
|
36
|
+
const text = typeof msg.content === "string" ? msg.content : extractText(msg.content);
|
|
37
|
+
if (!text.trim()) return undefined;
|
|
38
|
+
if (msg.role === "user") return `[User]: ${text.trim()}`;
|
|
39
|
+
if (msg.role === "assistant") return `[Assistant]: ${text.trim()}`;
|
|
40
|
+
return undefined; // skip toolResult and other roles
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Format a compaction entry; returns undefined when no summary is present. */
|
|
44
|
+
function formatCompactionEntry(entry: CompactionEntry): string | undefined {
|
|
45
|
+
return entry.summary ? `[Summary]: ${entry.summary}` : undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Dispatch a branch entry to the appropriate formatter. */
|
|
49
|
+
function formatBranchEntry(entry: BranchEntry): string | undefined {
|
|
50
|
+
if (entry.type === "message") return formatMessageEntry(entry as MessageEntry);
|
|
51
|
+
if (entry.type === "compaction") return formatCompactionEntry(entry as CompactionEntry);
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
33
55
|
/**
|
|
34
56
|
* Build a text representation of the parent conversation context.
|
|
35
57
|
* Used when inherit_context is true to give the subagent visibility
|
|
@@ -40,30 +62,9 @@ export function buildParentContext(ctx: SessionContext): string {
|
|
|
40
62
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- getBranch() may return undefined at runtime despite its type
|
|
41
63
|
if (!entries || entries.length === 0) return "";
|
|
42
64
|
|
|
43
|
-
const parts
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
if (rawEntry.type === "message") {
|
|
47
|
-
const entry = rawEntry as MessageEntry;
|
|
48
|
-
const msg = entry.message;
|
|
49
|
-
if (msg.role === "user") {
|
|
50
|
-
const text = typeof msg.content === "string"
|
|
51
|
-
? msg.content
|
|
52
|
-
: extractText(msg.content);
|
|
53
|
-
if (text.trim()) parts.push(`[User]: ${text.trim()}`);
|
|
54
|
-
} else if (msg.role === "assistant") {
|
|
55
|
-
const text = typeof msg.content === "string" ? msg.content : extractText(msg.content);
|
|
56
|
-
if (text.trim()) parts.push(`[Assistant]: ${text.trim()}`);
|
|
57
|
-
}
|
|
58
|
-
// Skip toolResult messages — too verbose for context
|
|
59
|
-
} else if (rawEntry.type === "compaction") {
|
|
60
|
-
// Include compaction summaries — they're already condensed
|
|
61
|
-
const entry = rawEntry as CompactionEntry;
|
|
62
|
-
if (entry.summary) {
|
|
63
|
-
parts.push(`[Summary]: ${entry.summary}`);
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
}
|
|
65
|
+
const parts = (entries as BranchEntry[])
|
|
66
|
+
.map(formatBranchEntry)
|
|
67
|
+
.filter((p): p is string => p !== undefined);
|
|
67
68
|
|
|
68
69
|
if (parts.length === 0) return "";
|
|
69
70
|
|