@gotgenes/pi-subagents 10.0.1 → 10.2.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 +41 -0
- package/docs/architecture/architecture.md +78 -159
- package/docs/architecture/history/phase-14-strip-policy.md +49 -0
- package/docs/plans/0227-evolve-agent-record-into-agent.md +322 -0
- package/docs/plans/0228-async-start-agent-dissolve-run-handle.md +288 -0
- package/docs/retro/0227-evolve-agent-record-into-agent.md +80 -0
- package/docs/retro/0228-async-start-agent-dissolve-run-handle.md +42 -0
- package/docs/retro/0239-collapse-filter-active-tools.md +33 -0
- package/package.json +1 -1
- package/src/lifecycle/agent-manager.ts +70 -207
- package/src/lifecycle/{agent-record.ts → agent.ts} +151 -13
- package/src/lifecycle/execution-state.ts +2 -2
- package/src/observation/notification.ts +8 -8
- package/src/observation/record-observer.ts +7 -7
- package/src/service/service-adapter.ts +8 -8
- package/src/tools/agent-tool.ts +4 -4
- package/src/tools/background-spawner.ts +2 -2
- package/src/tools/foreground-runner.ts +4 -4
- package/src/tools/get-result-tool.ts +2 -2
- package/src/tools/steer-tool.ts +4 -5
- package/src/types.ts +1 -1
- package/src/ui/agent-creation-wizard.ts +2 -2
- package/src/ui/agent-menu.ts +5 -5
- package/src/ui/conversation-viewer.ts +3 -3
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
---
|
|
2
|
+
issue: 227
|
|
3
|
+
issue_title: "Evolve AgentRecord into Agent with behavior (Phase 15, Step 1)"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Retro: #227 — Evolve AgentRecord into Agent with behavior
|
|
7
|
+
|
|
8
|
+
## Stage: Planning (2026-05-27T12:00:00Z)
|
|
9
|
+
|
|
10
|
+
### Session summary
|
|
11
|
+
|
|
12
|
+
Produced an 8-step TDD plan to move per-agent behavior (`abort`, `queueSteer`/`flushPendingSteers`, `setupWorktree`) from `AgentManager` into `AgentRecord`, then rename `AgentRecord` → `Agent` across the codebase.
|
|
13
|
+
The plan follows a "add behavior first, rename last" strategy to keep behavior diffs small and the rename commit purely mechanical.
|
|
14
|
+
|
|
15
|
+
### Observations
|
|
16
|
+
|
|
17
|
+
- `AgentRecord` is internal-only (public API is `SubagentRecord` in `service.ts`), so the rename is non-breaking.
|
|
18
|
+
- The `queueSteer` method can be removed from `AgentManagerLike` and `SteerToolManager` interfaces entirely — both callers (`steer-tool`, `service-adapter`) already hold the agent reference from `getRecord()`, so they can call `agent.queueSteer()` directly.
|
|
19
|
+
- Queue removal in `abort()` must stay on `AgentManager` until #230 extracts `ConcurrencyQueue`.
|
|
20
|
+
- `RunHandle` ownership explicitly deferred to #228 — the plan does not touch `RunHandle` at all.
|
|
21
|
+
- The rename step (step 7) touches ~30 files but is purely mechanical; all behavior changes land in steps 1–6.
|
|
22
|
+
|
|
23
|
+
## Stage: Implementation — TDD (2026-05-27T13:00:00Z)
|
|
24
|
+
|
|
25
|
+
### Session summary
|
|
26
|
+
|
|
27
|
+
Completed all 8 TDD steps from the plan.
|
|
28
|
+
Added 9 new tests (steer buffering, `abort()`, `setupWorktree()`) and migrated 977 existing tests to the renamed `Agent` class.
|
|
29
|
+
Test count went from 977 to 986 across 62 test files.
|
|
30
|
+
|
|
31
|
+
### Observations
|
|
32
|
+
|
|
33
|
+
- Fallow reported `AgentInit` and `AgentStatus` as unused type exports from `types.ts`; suppressed with `// fallow-ignore-next-line unused-type` (correct singular form — tool's error message hints at this).
|
|
34
|
+
- `ESLint` auto-removed an `as any` cast in the `setupWorktree` test (the mock `WorktreeManager` already satisfied the interface structurally); staged and re-committed cleanly.
|
|
35
|
+
- Biome auto-formatted several test files during the rename commit; re-staged and re-committed.
|
|
36
|
+
- Pre-completion reviewer returned **WARN** for 4 stale diagram/table references in `architecture.md` and the `package-pi-subagents` skill table; all fixed before the final commit.
|
|
37
|
+
- No deviations from the plan's behavior design; the `queueSteer` removal from manager interfaces worked exactly as anticipated in the retro notes.
|
|
38
|
+
|
|
39
|
+
## Stage: Final Retrospective (2026-05-27T17:22:00Z)
|
|
40
|
+
|
|
41
|
+
### Session summary
|
|
42
|
+
|
|
43
|
+
Completed all stages in a single session: planning, 8 TDD steps, pre-completion review, shipping, and release as `pi-subagents-v10.1.0`.
|
|
44
|
+
Three behaviors (`abort`, steer buffering, worktree setup) moved from `AgentManager` to `Agent`, followed by a codebase-wide rename (33 files).
|
|
45
|
+
|
|
46
|
+
### Observations
|
|
47
|
+
|
|
48
|
+
#### What went well
|
|
49
|
+
|
|
50
|
+
- The "add behavior first, rename last" strategy kept behavior-adding commits small (1–2 files each) and the rename commit purely mechanical.
|
|
51
|
+
- Planning identified that `queueSteer` could be removed from `AgentManagerLike` and `SteerToolManager` entirely — this simplified the delegation step and eliminated an unnecessary indirection layer.
|
|
52
|
+
- Pre-completion reviewer caught 4 stale Mermaid diagram references and a skill table entry that the plan's step 8 did not anticipate; all fixed before shipping.
|
|
53
|
+
|
|
54
|
+
#### What caused friction (agent side)
|
|
55
|
+
|
|
56
|
+
1. `scope-drift` — Added `AgentInit` and `AgentStatus` to the `types.ts` re-export barrel during the rename step without verifying any file imports them from that path.
|
|
57
|
+
Impact: fallow flagged dead code, triggering a 4-call suppression trial (`unused-export` → `unused-types` → `unused-type`), then the user identified the real fix (remove the speculative re-exports entirely), requiring a follow-up `fix:` commit after docs were already done.
|
|
58
|
+
2. `missing-context` — During the mechanical rename (step 7), `sed` commands matched `#test/helpers/make-record` but missed the relative import `"./helpers/make-record"` in `conversation-viewer.test.ts`.
|
|
59
|
+
Impact: `pnpm run check` caught it in 1 tool call; minimal rework.
|
|
60
|
+
3. `missing-context` — The fallow skill documents `unused-export` as a suppression kind but not `unused-type`.
|
|
61
|
+
Impact: 3 wrong guesses before the correct suppression syntax.
|
|
62
|
+
Self-identified after fallow's error message suggested the correct kind name.
|
|
63
|
+
|
|
64
|
+
#### What caused friction (user side)
|
|
65
|
+
|
|
66
|
+
- The user's question about whether the fallow suppressions could be removed in a future step was a valuable prompt — it surfaced that the re-exports were speculative and could be removed immediately.
|
|
67
|
+
Earlier intervention (e.g., during the TDD stage when the suppressions were added) would have avoided the `fix:` commit.
|
|
68
|
+
|
|
69
|
+
### Diagnostic details
|
|
70
|
+
|
|
71
|
+
- **Model-performance correlation** — Pre-completion reviewer ran as `pre-completion-reviewer` subagent (default model); appropriate for judgment-heavy work (doc staleness, code design review).
|
|
72
|
+
No model mismatches.
|
|
73
|
+
- **Feedback-loop gap analysis** — `pnpm run check` was run after every delegation step (steps 2, 4, 6, 7) and after every behavior-adding step (steps 1, 3, 5).
|
|
74
|
+
Verification was incremental throughout, not deferred to the end.
|
|
75
|
+
The `conversation-viewer.test.ts` import miss in step 7 was caught immediately by the type checker.
|
|
76
|
+
|
|
77
|
+
### Changes made
|
|
78
|
+
|
|
79
|
+
1. `.pi/skills/fallow/SKILL.md` — Added `unused-type` suppression example alongside existing `unused-export` example.
|
|
80
|
+
2. `AGENTS.md` — Added "no speculative re-exports" rule to Code Style section.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
---
|
|
2
|
+
issue: 228
|
|
3
|
+
issue_title: "Convert startAgent to async/await, move run lifecycle to Agent (Phase 15, Step 2)"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Retro: #228 — Convert startAgent to async/await, move run lifecycle to Agent
|
|
7
|
+
|
|
8
|
+
## Stage: Planning (2026-05-27T20:00:00Z)
|
|
9
|
+
|
|
10
|
+
### Session summary
|
|
11
|
+
|
|
12
|
+
Planned the async `startAgent` conversion and decided to dissolve `RunHandle` into Agent methods rather than moving it as a separate class.
|
|
13
|
+
Identified three preparatory steps (narrow promise type, add Agent methods, hoist worktree setup) that make the final async conversion a minimal diff.
|
|
14
|
+
|
|
15
|
+
### Observations
|
|
16
|
+
|
|
17
|
+
- The original issue proposed `Agent.createRunHandle()` as a factory, keeping RunHandle as a separate class.
|
|
18
|
+
Analysis showed 5 of 6 RunHandle concerns are Agent state mutations — RunHandle is doing work that belongs on Agent.
|
|
19
|
+
The clincher was `resume()` in `agent-manager.ts`: it duplicates RunHandle's pattern manually, and #232 wants to unify them.
|
|
20
|
+
Dissolving RunHandle gives both `startAgent` and `resume` the same primitives (`completeRun`, `failRun`, `releaseListeners`).
|
|
21
|
+
- The synchronous-throw contract in `spawn()` for worktree failures requires hoisting `record.setupWorktree()` out of `startAgent` before the async conversion.
|
|
22
|
+
Without this prep step, async `startAgent` would turn the throw into a rejected promise that `spawn()` doesn't catch.
|
|
23
|
+
- `promise: Promise<string>` → `Promise<void>` is safe because the resolved string is dead — every consumer reads `record.result` instead.
|
|
24
|
+
Only one test assertion reads the resolved value.
|
|
25
|
+
- `completeRun`/`failRun` take `worktrees: WorktreeManager` as a parameter rather than storing it on Agent (ISP — only needed at run end, exactly two callers).
|
|
26
|
+
|
|
27
|
+
## Stage: Implementation — TDD (2026-05-27T20:40:00Z)
|
|
28
|
+
|
|
29
|
+
### Session summary
|
|
30
|
+
|
|
31
|
+
Implemented all 6 TDD steps: narrowed `promise` to `Promise<void>`, added 6 run lifecycle methods to Agent (+19 tests), replaced `RunHandle` with Agent methods (-85 LOC), hoisted worktree setup to callers, converted `startAgent` to async/await, and updated architecture docs.
|
|
32
|
+
Test count: 986 → 1005.
|
|
33
|
+
|
|
34
|
+
### Observations
|
|
35
|
+
|
|
36
|
+
- Step 1 (promise narrowing) required fixing 3 additional test files not listed in the plan: `make-agent.test.ts`, `service-adapter.test.ts`, `get-result-tool.test.ts`.
|
|
37
|
+
All were trivial `Promise.resolve("done")` → `Promise.resolve()` changes and a cast removal.
|
|
38
|
+
- The lift-and-shift approach worked cleanly — each of the 5 implementation commits was small and independently green.
|
|
39
|
+
The most impactful commit was step 3 (replace RunHandle, -96/+6 lines) which was risk-free because step 2 had already introduced the Agent methods.
|
|
40
|
+
- Pre-completion reviewer returned WARN for stale `AgentRecord` and `run-handle.ts` references in `architecture.md` class diagram and layout listing.
|
|
41
|
+
These were pre-existing staleness from #227's rename that wasn't fully propagated to Mermaid diagrams.
|
|
42
|
+
Fixed by amending the docs commit.
|
|
@@ -35,3 +35,36 @@ A follow-up skill maintenance commit updated `.pi/skills/package-pi-subagents/SK
|
|
|
35
35
|
- Pre-completion reviewer returned **WARN** for stale `package-pi-subagents` skill content: the "Patch 2 scheduled for removal" note and the `// Patch 2 (RepOne` grep instruction were both stale after #239 completion.
|
|
36
36
|
Fixed immediately as a follow-up `docs:` commit before writing retro notes.
|
|
37
37
|
- `pnpm fallow dead-code` passed with 0 issues — no orphaned exports left behind.
|
|
38
|
+
|
|
39
|
+
## Stage: Final Retrospective (2026-05-27T14:40:00Z)
|
|
40
|
+
|
|
41
|
+
### Session summary
|
|
42
|
+
|
|
43
|
+
Completed the full issue lifecycle (plan → TDD → ship → retro) in a single continuous session.
|
|
44
|
+
Issue #239 shipped as `pi-subagents-v10.0.1` with 7 commits (2 refactor, 4 docs, 1 release).
|
|
45
|
+
Phase 14 is now fully complete, unblocking Phase 15 (#227–#232).
|
|
46
|
+
|
|
47
|
+
### Observations
|
|
48
|
+
|
|
49
|
+
#### What went well
|
|
50
|
+
|
|
51
|
+
- The plan's type-dependency-chain ordering (`SessionConfig` first → expected compile errors → `agent-runner.ts` resolves them) produced zero surprises during TDD.
|
|
52
|
+
Each step's red/green boundary was exactly where the plan predicted.
|
|
53
|
+
- Pre-completion reviewer caught stale `package-pi-subagents` skill content ("Patch 2 scheduled for removal" and a `// Patch 2 (RepOne` grep instruction) that no longer matched source.
|
|
54
|
+
Fixed before shipping — the reviewer earned its keep.
|
|
55
|
+
- Multi-model routing was well-matched: `claude-sonnet-4-6` for planning and TDD (judgment + code), `deepseek-v4-flash` for shipping (mechanical checklist), `claude-opus-4-6` for retrospective (synthesis).
|
|
56
|
+
- Feedback loops were incremental: `pnpm vitest run` after each test change, `pnpm run check` after Step 1 to confirm expected errors, full suite + lint + dead-code after all steps.
|
|
57
|
+
|
|
58
|
+
#### What caused friction (agent side)
|
|
59
|
+
|
|
60
|
+
No friction points identified.
|
|
61
|
+
This was the final step of a 3-step phase with both dependencies already closed, a well-scoped plan, and internal-only API changes — the simplest possible lifecycle.
|
|
62
|
+
|
|
63
|
+
#### What caused friction (user side)
|
|
64
|
+
|
|
65
|
+
None observed.
|
|
66
|
+
The user's involvement was limited to issuing the four standard lifecycle commands (`/plan-issue`, `/tdd-plan`, `/ship-issue`, `/retro`) with no corrections or redirections needed.
|
|
67
|
+
|
|
68
|
+
### Changes made
|
|
69
|
+
|
|
70
|
+
1. Appended Final Retrospective stage entry to `packages/pi-subagents/docs/retro/0239-collapse-filter-active-tools.md`.
|
package/package.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* agent-manager.ts
|
|
2
|
+
* agent-manager.ts - Tracks agents, background execution, resume support.
|
|
3
3
|
*
|
|
4
4
|
* Background agents are subject to a configurable concurrency limit (default: 4).
|
|
5
5
|
* Excess agents are queued and auto-started as running agents complete.
|
|
@@ -11,114 +11,25 @@ import type { Model } from "@earendil-works/pi-ai";
|
|
|
11
11
|
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
|
-
import {
|
|
15
|
-
import type { AgentRunner
|
|
14
|
+
import { Agent } from "#src/lifecycle/agent";
|
|
15
|
+
import type { AgentRunner } 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
|
+
|
|
19
19
|
import { NotificationState } from "#src/observation/notification-state";
|
|
20
|
-
import {
|
|
20
|
+
import { subscribeAgentObserver } 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
|
-
|
|
113
24
|
export type CompactionInfo = { reason: "manual" | "threshold" | "overflow"; tokensBefore: number };
|
|
114
25
|
|
|
115
26
|
/** Observer interface for agent lifecycle notifications. */
|
|
116
27
|
export interface AgentManagerObserver {
|
|
117
|
-
onAgentStarted(record:
|
|
118
|
-
onAgentCompleted(record:
|
|
119
|
-
onAgentCompacted(record:
|
|
28
|
+
onAgentStarted(record: Agent): void;
|
|
29
|
+
onAgentCompleted(record: Agent): void;
|
|
30
|
+
onAgentCompacted(record: Agent, info: CompactionInfo): void;
|
|
120
31
|
/** Fires synchronously after a background agent record is created (before startAgent). */
|
|
121
|
-
onAgentCreated(record:
|
|
32
|
+
onAgentCreated(record: Agent): void;
|
|
122
33
|
}
|
|
123
34
|
|
|
124
35
|
/** Default max concurrent background agents. */
|
|
@@ -129,7 +40,7 @@ export interface AgentManagerOptions {
|
|
|
129
40
|
worktrees: WorktreeManager;
|
|
130
41
|
exec: ShellExec;
|
|
131
42
|
registry: AgentTypeRegistry;
|
|
132
|
-
/** Injected getter for the concurrency limit
|
|
43
|
+
/** Injected getter for the concurrency limit - owned by SettingsManager. */
|
|
133
44
|
getMaxConcurrent?: () => number;
|
|
134
45
|
getRunConfig?: () => RunConfig;
|
|
135
46
|
observer?: AgentManagerObserver;
|
|
@@ -160,25 +71,25 @@ export interface AgentSpawnConfig {
|
|
|
160
71
|
thinkingLevel?: ThinkingLevel;
|
|
161
72
|
isBackground?: boolean;
|
|
162
73
|
/**
|
|
163
|
-
* Skip the maxConcurrent queue check for this spawn
|
|
74
|
+
* Skip the maxConcurrent queue check for this spawn - start immediately even
|
|
164
75
|
* if the configured concurrency limit would otherwise queue it. Useful for
|
|
165
76
|
* callers (e.g. cross-extension RPC) that must not be deferred by the queue.
|
|
166
77
|
*/
|
|
167
78
|
bypassQueue?: boolean;
|
|
168
|
-
/** Isolation mode
|
|
79
|
+
/** Isolation mode - "worktree" creates a temp git worktree for the agent. */
|
|
169
80
|
isolation?: IsolationMode;
|
|
170
81
|
/** Resolved invocation snapshot captured for UI display. */
|
|
171
82
|
invocation?: AgentInvocation;
|
|
172
|
-
/** Parent abort signal
|
|
83
|
+
/** Parent abort signal - when aborted, the subagent is also stopped. */
|
|
173
84
|
signal?: AbortSignal;
|
|
174
|
-
/** Called when the agent session is created
|
|
175
|
-
onSessionCreated?: (session: AgentSession, record:
|
|
176
|
-
/** Parent session identity
|
|
85
|
+
/** Called when the agent session is created - receives the session and the agent's record. */
|
|
86
|
+
onSessionCreated?: (session: AgentSession, record: Agent) => void;
|
|
87
|
+
/** Parent session identity - grouped fields that travel together from the tool boundary. */
|
|
177
88
|
parentSession?: ParentSessionInfo;
|
|
178
89
|
}
|
|
179
90
|
|
|
180
91
|
export class AgentManager {
|
|
181
|
-
private agents = new Map<string,
|
|
92
|
+
private agents = new Map<string, Agent>();
|
|
182
93
|
private cleanupInterval: ReturnType<typeof setInterval>;
|
|
183
94
|
private readonly observer?: AgentManagerObserver;
|
|
184
95
|
private readonly runner: AgentRunner;
|
|
@@ -192,9 +103,6 @@ export class AgentManager {
|
|
|
192
103
|
private queue: { id: string; args: SpawnArgs }[] = [];
|
|
193
104
|
/** Number of currently running background agents. */
|
|
194
105
|
private runningBackground = 0;
|
|
195
|
-
/** Steers buffered for agents whose session hasn’t been created yet. */
|
|
196
|
-
private pendingSteers = new Map<string, string[]>();
|
|
197
|
-
|
|
198
106
|
constructor(options: AgentManagerOptions) {
|
|
199
107
|
this.runner = options.runner;
|
|
200
108
|
this.worktrees = options.worktrees;
|
|
@@ -216,19 +124,6 @@ export class AgentManager {
|
|
|
216
124
|
this.drainQueue();
|
|
217
125
|
}
|
|
218
126
|
|
|
219
|
-
/**
|
|
220
|
-
* Buffer a steer message for an agent whose session isn’t ready yet.
|
|
221
|
-
* Returns false if the agent id is not tracked (already cleaned up or unknown).
|
|
222
|
-
* Called by steer-tool and service-adapter when record.execution is undefined.
|
|
223
|
-
*/
|
|
224
|
-
queueSteer(id: string, message: string): boolean {
|
|
225
|
-
if (!this.agents.has(id)) return false;
|
|
226
|
-
const steers = this.pendingSteers.get(id) ?? [];
|
|
227
|
-
steers.push(message);
|
|
228
|
-
this.pendingSteers.set(id, steers);
|
|
229
|
-
return true;
|
|
230
|
-
}
|
|
231
|
-
|
|
232
127
|
/**
|
|
233
128
|
* Spawn an agent and return its ID immediately (for background use).
|
|
234
129
|
* If the concurrency limit is reached, the agent is queued.
|
|
@@ -241,7 +136,7 @@ export class AgentManager {
|
|
|
241
136
|
): string {
|
|
242
137
|
const id = randomUUID().slice(0, 17);
|
|
243
138
|
const abortController = new AbortController();
|
|
244
|
-
const record = new
|
|
139
|
+
const record = new Agent({
|
|
245
140
|
id,
|
|
246
141
|
type,
|
|
247
142
|
description: options.description,
|
|
@@ -263,15 +158,16 @@ export class AgentManager {
|
|
|
263
158
|
const args: SpawnArgs = { snapshot, type, prompt, options };
|
|
264
159
|
|
|
265
160
|
if (options.isBackground && !options.bypassQueue && this.runningBackground >= this._getMaxConcurrent()) {
|
|
266
|
-
// Queue it
|
|
161
|
+
// Queue it - will be started when a running agent completes
|
|
267
162
|
this.queue.push({ id, args });
|
|
268
163
|
return id;
|
|
269
164
|
}
|
|
270
165
|
|
|
271
|
-
//
|
|
166
|
+
// setupWorktree can throw (e.g. strict worktree-isolation failure) - clean
|
|
272
167
|
// up the record so callers don't see an orphan in `listAgents()`.
|
|
273
168
|
try {
|
|
274
|
-
this.
|
|
169
|
+
record.setupWorktree(this.worktrees, options.isolation);
|
|
170
|
+
record.promise = this.startAgent(id, record, args);
|
|
275
171
|
} catch (err) {
|
|
276
172
|
this.agents.delete(id);
|
|
277
173
|
throw err;
|
|
@@ -280,79 +176,53 @@ export class AgentManager {
|
|
|
280
176
|
}
|
|
281
177
|
|
|
282
178
|
/** Actually start an agent (called immediately or from queue drain). */
|
|
283
|
-
private startAgent(id: string, record:
|
|
284
|
-
const worktreeCwd = this.setupWorktree(id, record, options.isolation);
|
|
285
|
-
|
|
179
|
+
private async startAgent(id: string, record: Agent, { snapshot, type, prompt, options }: SpawnArgs): Promise<void> {
|
|
286
180
|
record.markRunning(Date.now());
|
|
287
181
|
if (options.isBackground) this.runningBackground++;
|
|
288
182
|
this.observer?.onAgentStarted(record);
|
|
289
183
|
|
|
290
|
-
|
|
291
|
-
record, this.worktrees,
|
|
184
|
+
record.setOnRunFinished(
|
|
292
185
|
options.isBackground ? () => this.finalizeBackgroundRun(record) : undefined,
|
|
293
186
|
);
|
|
294
|
-
|
|
187
|
+
record.wireSignal(options.signal, () => this.abort(id));
|
|
295
188
|
|
|
296
189
|
const runConfig = this.getRunConfig?.();
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
.
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
}
|
|
343
|
-
|
|
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(() => {});
|
|
190
|
+
try {
|
|
191
|
+
const result = await this.runner.run(snapshot, type, prompt, {
|
|
192
|
+
context: {
|
|
193
|
+
exec: this.exec,
|
|
194
|
+
registry: this.registry,
|
|
195
|
+
cwd: record.worktreeState?.path,
|
|
196
|
+
parentSession: options.parentSession,
|
|
197
|
+
},
|
|
198
|
+
model: options.model,
|
|
199
|
+
maxTurns: options.maxTurns,
|
|
200
|
+
defaultMaxTurns: runConfig?.defaultMaxTurns,
|
|
201
|
+
graceTurns: runConfig?.graceTurns,
|
|
202
|
+
isolated: options.isolated,
|
|
203
|
+
thinkingLevel: options.thinkingLevel,
|
|
204
|
+
signal: record.abortController!.signal,
|
|
205
|
+
onSessionCreated: (session) => {
|
|
206
|
+
// Capture the session file path early so it's available for display
|
|
207
|
+
// before the run completes (e.g. in background agent status messages).
|
|
208
|
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- sessionManager is typed as always present but Pi SDK may not provide it
|
|
209
|
+
const outputFile = session.sessionManager?.getSessionFile?.() ?? undefined;
|
|
210
|
+
record.execution = { session, outputFile };
|
|
211
|
+
record.flushPendingSteers(session);
|
|
212
|
+
record.attachObserver(subscribeAgentObserver(session, record, {
|
|
213
|
+
onCompact: (r, info) => this.observer?.onAgentCompacted(r, info),
|
|
214
|
+
}));
|
|
215
|
+
options.onSessionCreated?.(session, record);
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
record.completeRun(result, this.worktrees);
|
|
219
|
+
} catch (err) {
|
|
220
|
+
record.failRun(err, this.worktrees);
|
|
350
221
|
}
|
|
351
|
-
this.pendingSteers.delete(id);
|
|
352
222
|
}
|
|
353
223
|
|
|
354
224
|
/** Decrement background counter, notify observer (crash-safe), and drain the queue. */
|
|
355
|
-
private finalizeBackgroundRun(record:
|
|
225
|
+
private finalizeBackgroundRun(record: Agent): void {
|
|
356
226
|
this.runningBackground--;
|
|
357
227
|
try { this.observer?.onAgentCompleted(record); } catch (err) { debugLog("onAgentCompleted observer", err); }
|
|
358
228
|
this.drainQueue();
|
|
@@ -365,9 +235,10 @@ export class AgentManager {
|
|
|
365
235
|
const record = this.agents.get(next.id);
|
|
366
236
|
if (record?.status !== "queued") continue;
|
|
367
237
|
try {
|
|
368
|
-
|
|
238
|
+
record.setupWorktree(this.worktrees, next.args.options.isolation);
|
|
239
|
+
record.promise = this.startAgent(next.id, record, next.args);
|
|
369
240
|
} catch (err) {
|
|
370
|
-
// Late failure (e.g. strict worktree-isolation)
|
|
241
|
+
// Late failure (e.g. strict worktree-isolation) - surface on the record
|
|
371
242
|
// so the user/agent can see it via /agents, then keep draining.
|
|
372
243
|
record.markError(err);
|
|
373
244
|
this.observer?.onAgentCompleted(record);
|
|
@@ -384,7 +255,7 @@ export class AgentManager {
|
|
|
384
255
|
type: SubagentType,
|
|
385
256
|
prompt: string,
|
|
386
257
|
options: Omit<AgentSpawnConfig, "isBackground">,
|
|
387
|
-
): Promise<
|
|
258
|
+
): Promise<Agent> {
|
|
388
259
|
const id = this.spawn(snapshot, type, prompt, { ...options, isBackground: false });
|
|
389
260
|
const record = this.agents.get(id)!;
|
|
390
261
|
await record.promise;
|
|
@@ -398,14 +269,14 @@ export class AgentManager {
|
|
|
398
269
|
id: string,
|
|
399
270
|
prompt: string,
|
|
400
271
|
signal?: AbortSignal,
|
|
401
|
-
): Promise<
|
|
272
|
+
): Promise<Agent | undefined> {
|
|
402
273
|
const record = this.agents.get(id);
|
|
403
274
|
const session = record?.session;
|
|
404
275
|
if (!session) return undefined;
|
|
405
276
|
|
|
406
277
|
record.resetForResume(Date.now());
|
|
407
278
|
|
|
408
|
-
const unsubResume =
|
|
279
|
+
const unsubResume = subscribeAgentObserver(session, record, {
|
|
409
280
|
onCompact: (r, info) => this.observer?.onAgentCompacted(r, info),
|
|
410
281
|
});
|
|
411
282
|
|
|
@@ -423,11 +294,11 @@ export class AgentManager {
|
|
|
423
294
|
return record;
|
|
424
295
|
}
|
|
425
296
|
|
|
426
|
-
getRecord(id: string):
|
|
297
|
+
getRecord(id: string): Agent | undefined {
|
|
427
298
|
return this.agents.get(id);
|
|
428
299
|
}
|
|
429
300
|
|
|
430
|
-
listAgents():
|
|
301
|
+
listAgents(): Agent[] {
|
|
431
302
|
return [...this.agents.values()].sort(
|
|
432
303
|
(a, b) => b.startedAt - a.startedAt,
|
|
433
304
|
);
|
|
@@ -444,18 +315,14 @@ export class AgentManager {
|
|
|
444
315
|
return true;
|
|
445
316
|
}
|
|
446
317
|
|
|
447
|
-
|
|
448
|
-
record.abortController?.abort();
|
|
449
|
-
record.markStopped();
|
|
450
|
-
return true;
|
|
318
|
+
return record.abort();
|
|
451
319
|
}
|
|
452
320
|
|
|
453
321
|
/** Dispose a record's session and remove it from the map. */
|
|
454
|
-
private removeRecord(id: string, record:
|
|
322
|
+
private removeRecord(id: string, record: Agent): void {
|
|
455
323
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- dispose may not exist on all session implementations
|
|
456
324
|
record.session?.dispose?.();
|
|
457
325
|
this.agents.delete(id);
|
|
458
|
-
this.pendingSteers.delete(id);
|
|
459
326
|
}
|
|
460
327
|
|
|
461
328
|
private cleanup() {
|
|
@@ -501,11 +368,7 @@ export class AgentManager {
|
|
|
501
368
|
this.queue = [];
|
|
502
369
|
// Abort running agents
|
|
503
370
|
for (const record of this.agents.values()) {
|
|
504
|
-
if (record.
|
|
505
|
-
record.abortController?.abort();
|
|
506
|
-
record.markStopped();
|
|
507
|
-
count++;
|
|
508
|
-
}
|
|
371
|
+
if (record.abort()) count++;
|
|
509
372
|
}
|
|
510
373
|
return count;
|
|
511
374
|
}
|
|
@@ -513,7 +376,7 @@ export class AgentManager {
|
|
|
513
376
|
/** Wait for all running and queued agents to complete (including queued ones). */
|
|
514
377
|
// fallow-ignore-next-line unused-class-member
|
|
515
378
|
async waitForAll(): Promise<void> {
|
|
516
|
-
// Loop because drainQueue respects the concurrency limit
|
|
379
|
+
// Loop because drainQueue respects the concurrency limit - as running
|
|
517
380
|
// agents finish they start queued ones, which need awaiting too.
|
|
518
381
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- intentional infinite loop with explicit break
|
|
519
382
|
while (true) {
|
|
@@ -521,7 +384,7 @@ export class AgentManager {
|
|
|
521
384
|
const pending = [...this.agents.values()]
|
|
522
385
|
.filter(r => r.status === "running" || r.status === "queued")
|
|
523
386
|
.map(r => r.promise)
|
|
524
|
-
.filter((p): p is Promise<
|
|
387
|
+
.filter((p): p is Promise<void> => p != null);
|
|
525
388
|
if (pending.length === 0) break;
|
|
526
389
|
await Promise.allSettled(pending);
|
|
527
390
|
}
|