@gotgenes/pi-subagents 11.1.0 → 11.3.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.
@@ -0,0 +1,45 @@
1
+ ---
2
+ issue: 256
3
+ issue_title: "Extract WorktreeIsolation collaborator"
4
+ ---
5
+
6
+ # Retro: #256 — Extract WorktreeIsolation collaborator
7
+
8
+ ## Stage: Planning (2026-05-28T23:44:23Z)
9
+
10
+ ### Session summary
11
+
12
+ Produced a numbered implementation plan for extracting a `WorktreeIsolation` collaborator (Phase 16, Step 1) that owns the worktree lifecycle (`setup`, `path`, `cleanup`) so `Agent` tells one collaborator instead of orchestrating `_worktrees` + `_isolation` + `worktreeState` itself.
13
+ The plan covers the new module, `Agent`/`AgentManager`/`service-adapter` wiring, the `WorktreeState` deletion, doc updates, and a 4-cycle TDD order.
14
+
15
+ ### Observations
16
+
17
+ - Decision: fold `WorktreeState` into `WorktreeIsolation` (delete `worktree-state.ts`) rather than wrap it.
18
+ The architecture target table already lists `WorktreeIsolation` as absorbing `worktrees` + `isolation` + `worktreeState`, and the user confirmed a fold preference when the doc had already decided it.
19
+ - `WorktreeManager.cleanup(wt, ...)` mutates `wt.branch` in place; `WorktreeIsolation` must store a mutable `WorktreeInfo` (`_info`) to preserve that behavior — flagged as the top risk.
20
+ - `AgentInit` net field change is −1 (removes `worktrees` + `isolation`, adds `worktree`), not −2 as the issue text loosely states; instance fields drop by 2 and `setupWorktree()` is removed.
21
+ - The `missing worktrees dependency` defensive branch becomes structurally impossible (collaborator is only built with a manager) and is dropped.
22
+ - Verified no consumer imports the `WorktreeCleanupResult`/`WorktreeInfo` re-exports from `worktree-state.ts` — they all import from `worktree.ts`, so deletion is safe.
23
+ - Step 2 (the integration) is a single commit because the type checker forbids removing `AgentInit` fields while call sites still pass them; bulk of `agent.test.ts` is untouched, only worktree helpers/describe blocks change.
24
+ - Doc updates needed: architecture class diagram + layout listing, and the package `SKILL.md` Lifecycle domain row (module count stays 9).
25
+ - This step is independent of Step 2 (#257, `ChildSessionFactory`) per the architecture's Track A.
26
+
27
+ ## Stage: Implementation — TDD (2026-05-29T00:01:54Z)
28
+
29
+ ### Session summary
30
+
31
+ Implemented all 4 planned TDD cycles: added `WorktreeIsolation` + unit tests, wired it into `Agent`/`AgentManager`/`service-adapter` (removing `_worktrees`/`_isolation`/`worktreeState`/`setupWorktree()`), deleted the folded `WorktreeState` class and its test, and updated the architecture doc + package skill.
32
+ Full suite green at 1047 tests (baseline 1053; +7 new `worktree-isolation` tests, −4 removed `setupWorktree` tests, −9 removed `worktree-state` tests); `check`, `lint`, and `fallow dead-code` all clean.
33
+
34
+ ### Observations
35
+
36
+ - One pre-existing baseline failure: `rumdl` flagged 5 orphaned issue link definitions (`[#227]`–`[#232]`, minus the still-used `[#231]`) in `architecture.md`, introduced by an earlier Phase 15 archive commit.
37
+ Fixed as a separate `docs:` cleanup commit before starting TDD to establish a green baseline.
38
+ - Deviation from a literal 1:1 test mapping: `WorktreeIsolation` deliberately exposes `path` + `cleanupResult` but no `branch` getter (branch is an internal `_info` detail surfaced via `cleanupResult`).
39
+ The two `agent-manager.test.ts` tests that asserted `worktreeState.branch` now assert `record.worktree?.path` and `record.worktree?.cleanupResult`.
40
+ Noted in the Step 2 commit body.
41
+ - `Agent.worktree` is `readonly` (set at construction), unlike the old mutable public `worktreeState` field.
42
+ Tests that previously mutated `record.worktreeState = new WorktreeState(...)` after construction were reworked to pass a pre-`setup()` `WorktreeIsolation` via the constructor (`createSetUpWorktree` helper in `agent.test.ts`; `setUpWorktree` helper in `service-adapter.test.ts`).
43
+ - `createTestAgent` spreads `init` into the `Agent` constructor, so injecting `worktree` needed no helper change.
44
+ - The Step 2 integration landed cleanly in a single commit as the plan predicted; the type checker pinpointed every stale call site.
45
+ - Pre-completion reviewer: PASS (all deterministic checks, acceptance criteria, conventional commits, docs, code design, test artifacts, Mermaid, and dead-code gates green).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-subagents",
3
- "version": "11.1.0",
3
+ "version": "11.3.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./src/service.ts"
@@ -14,8 +14,8 @@ import type { AgentRunner } from "#src/lifecycle/agent-runner";
14
14
  import type { ConcurrencyQueue } from "#src/lifecycle/concurrency-queue";
15
15
  import type { ParentSnapshot } from "#src/lifecycle/parent-snapshot";
16
16
  import type { WorktreeManager } from "#src/lifecycle/worktree";
17
+ import { WorktreeIsolation } from "#src/lifecycle/worktree-isolation";
17
18
 
18
- import { subscribeAgentObserver } from "#src/observation/record-observer";
19
19
  import type { RunConfig } from "#src/runtime";
20
20
  import type { AgentInvocation, CompactionInfo, IsolationMode, ParentSessionInfo, SubagentType, ThinkingLevel } from "#src/types";
21
21
 
@@ -130,12 +130,14 @@ export class AgentManager {
130
130
  maxTurns: options.maxTurns,
131
131
  isolated: options.isolated,
132
132
  thinkingLevel: options.thinkingLevel,
133
- isolation: options.isolation,
134
133
  parentSession: options.parentSession,
135
134
  signal: options.signal,
136
135
  // Shared deps
137
136
  runner: this.runner,
138
- worktrees: this.worktrees,
137
+ worktree:
138
+ options.isolation === "worktree"
139
+ ? new WorktreeIsolation(this.worktrees, id)
140
+ : undefined,
139
141
  observer: this.buildObserver(options),
140
142
  getRunConfig: this.getRunConfig,
141
143
  });
@@ -173,34 +175,17 @@ export class AgentManager {
173
175
 
174
176
  /**
175
177
  * Resume an existing agent session with a new prompt.
178
+ * Delegates to Agent.resume(), which owns the observer subscription lifecycle.
176
179
  */
177
180
  async resume(
178
181
  id: string,
179
182
  prompt: string,
180
183
  signal?: AbortSignal,
181
184
  ): Promise<Agent | undefined> {
182
- const record = this.agents.get(id);
183
- const session = record?.session;
184
- if (!session) return undefined;
185
-
186
- record.resetForResume(Date.now());
187
-
188
- const unsubResume = subscribeAgentObserver(session, record, {
189
- onCompact: (r, info) => this.observer?.onAgentCompacted(r, info),
190
- });
191
-
192
- try {
193
- const responseText = await this.runner.resume(session, prompt, {
194
- signal,
195
- });
196
- record.markCompleted(responseText);
197
- } catch (err) {
198
- record.markError(err);
199
- } finally {
200
- unsubResume();
201
- }
202
-
203
- return record;
185
+ const agent = this.agents.get(id);
186
+ if (!agent?.session) return undefined;
187
+ await agent.resume(prompt, signal);
188
+ return agent;
204
189
  }
205
190
 
206
191
  getRecord(id: string): Agent | undefined {
@@ -11,7 +11,10 @@
11
11
  * Behavior (abort, steer buffering, worktree setup) lives on the agent
12
12
  * rather than on AgentManager — each agent manages its own lifecycle concerns.
13
13
  *
14
- * Phase-specific collaborators (execution, worktreeState, notification) are attached
14
+ * Worktree isolation is delegated to an optional WorktreeIsolation collaborator
15
+ * (set at construction when isolation is requested); its presence IS the mode.
16
+ *
17
+ * Phase-specific collaborators (execution, notification) are attached
15
18
  * after construction as lifecycle information becomes available.
16
19
  */
17
20
 
@@ -23,12 +26,11 @@ import type { ExecutionState } from "#src/lifecycle/execution-state";
23
26
  import type { ParentSnapshot } from "#src/lifecycle/parent-snapshot";
24
27
  import type { LifetimeUsage } from "#src/lifecycle/usage";
25
28
  import { addUsage } from "#src/lifecycle/usage";
26
- import type { WorktreeManager } from "#src/lifecycle/worktree";
27
- import { WorktreeState } from "#src/lifecycle/worktree-state";
29
+ import type { WorktreeIsolation } from "#src/lifecycle/worktree-isolation";
28
30
  import { NotificationState } from "#src/observation/notification-state";
29
31
  import { subscribeAgentObserver } from "#src/observation/record-observer";
30
32
  import type { RunConfig } from "#src/runtime";
31
- import type { AgentInvocation, CompactionInfo, IsolationMode, ParentSessionInfo, SubagentType, ThinkingLevel } from "#src/types";
33
+ import type { AgentInvocation, CompactionInfo, ParentSessionInfo, SubagentType, ThinkingLevel } from "#src/types";
32
34
 
33
35
  /** Per-agent lifecycle observer — created by AgentManager for each spawn. */
34
36
  export interface AgentLifecycleObserver {
@@ -67,7 +69,7 @@ export interface AgentInit {
67
69
 
68
70
  // Shared deps (required for run(), optional for tests)
69
71
  runner?: AgentRunner;
70
- worktrees?: WorktreeManager;
72
+ worktree?: WorktreeIsolation;
71
73
  observer?: AgentLifecycleObserver;
72
74
  getRunConfig?: () => RunConfig;
73
75
 
@@ -78,7 +80,6 @@ export interface AgentInit {
78
80
  maxTurns?: number;
79
81
  isolated?: boolean;
80
82
  thinkingLevel?: ThinkingLevel;
81
- isolation?: IsolationMode;
82
83
  parentSession?: ParentSessionInfo;
83
84
  isBackground?: boolean;
84
85
  signal?: AbortSignal;
@@ -124,7 +125,8 @@ export class Agent {
124
125
 
125
126
  // Shared deps — optional (required for run())
126
127
  private readonly _runner?: AgentRunner;
127
- private readonly _worktrees?: WorktreeManager;
128
+ /** Worktree isolation collaborator — present only when isolation: "worktree". */
129
+ readonly worktree?: WorktreeIsolation;
128
130
  readonly observer?: AgentLifecycleObserver;
129
131
  private readonly _getRunConfig?: () => RunConfig;
130
132
 
@@ -135,37 +137,13 @@ export class Agent {
135
137
  private readonly _maxTurns?: number;
136
138
  private readonly _isolated?: boolean;
137
139
  private readonly _thinkingLevel?: ThinkingLevel;
138
- private readonly _isolation?: IsolationMode;
139
140
  private readonly _parentSession?: ParentSessionInfo;
140
141
  private readonly _signal?: AbortSignal;
141
142
 
142
143
  // Phase-specific collaborators — each born complete when their info becomes available
143
144
  execution?: ExecutionState;
144
- worktreeState?: WorktreeState;
145
145
  notification?: NotificationState;
146
146
 
147
- /**
148
- * Create a git worktree for isolated execution, set worktreeState, and return the worktree path.
149
- * Returns undefined if isolation is not "worktree".
150
- * Throws if worktree creation fails (strict isolation).
151
- * Uses this._worktrees and this._isolation (set at construction).
152
- */
153
- setupWorktree(): string | undefined {
154
- if (this._isolation !== "worktree") return undefined;
155
- if (!this._worktrees) {
156
- throw new Error("Agent not configured for worktree isolation — missing worktrees dependency");
157
- }
158
- const wt = this._worktrees.create(this.id);
159
- if (!wt) {
160
- throw new Error(
161
- 'Cannot run with isolation: "worktree" — not a git repo, no commits yet, or `git worktree add` failed. ' +
162
- 'Initialize git and commit at least once, or omit `isolation`.',
163
- );
164
- }
165
- this.worktreeState = new WorktreeState(wt);
166
- return wt.path;
167
- }
168
-
169
147
  // Steer buffer — messages queued before the session is ready
170
148
  private _pendingSteers: string[] = [];
171
149
  /** Number of steer messages waiting to be delivered. */
@@ -205,7 +183,7 @@ export class Agent {
205
183
 
206
184
  // Shared deps
207
185
  this._runner = init.runner;
208
- this._worktrees = init.worktrees;
186
+ this.worktree = init.worktree;
209
187
  this.observer = init.observer;
210
188
  this._getRunConfig = init.getRunConfig;
211
189
 
@@ -216,7 +194,6 @@ export class Agent {
216
194
  this._maxTurns = init.maxTurns;
217
195
  this._isolated = init.isolated;
218
196
  this._thinkingLevel = init.thinkingLevel;
219
- this._isolation = init.isolation;
220
197
  this._parentSession = init.parentSession;
221
198
  this._signal = init.signal;
222
199
 
@@ -247,9 +224,10 @@ export class Agent {
247
224
  this.wireSignal(this._signal, () => this.abort());
248
225
 
249
226
  try {
250
- this.setupWorktree();
227
+ this.worktree?.setup();
251
228
  } catch (err) {
252
229
  this.markError(err);
230
+ this.releaseListeners();
253
231
  this.observer?.onRunFinished?.(this);
254
232
  return;
255
233
  }
@@ -258,7 +236,7 @@ export class Agent {
258
236
  try {
259
237
  const result = await this._runner.run(this._snapshot, this.type, this._prompt, {
260
238
  context: {
261
- cwd: this.worktreeState?.path,
239
+ cwd: this.worktree?.path,
262
240
  parentSession: this._parentSession,
263
241
  },
264
242
  model: this._model,
@@ -285,6 +263,39 @@ export class Agent {
285
263
  }
286
264
  }
287
265
 
266
+ /**
267
+ * Resume an existing session with a new prompt, managing the observer
268
+ * subscription lifecycle internally (same wiring as run()).
269
+ *
270
+ * Requires runner and an existing session (set when the original run created it).
271
+ * The returned promise always resolves (errors are captured internally).
272
+ * The parent signal flows straight through to runner.resume — resume does not
273
+ * route through this.abortController.
274
+ */
275
+ async resume(prompt: string, signal?: AbortSignal): Promise<void> {
276
+ if (!this._runner) {
277
+ throw new Error("Agent not configured for execution — missing runner");
278
+ }
279
+ const session = this.session;
280
+ if (!session) {
281
+ throw new Error("Agent not configured for resume — missing session");
282
+ }
283
+
284
+ this.resetForResume(Date.now());
285
+ this.attachObserver(subscribeAgentObserver(session, this, {
286
+ onCompact: (r, info) => this.observer?.onCompacted?.(r, info),
287
+ }));
288
+
289
+ try {
290
+ const responseText = await this._runner.resume(session, prompt, { signal });
291
+ this.markCompleted(responseText);
292
+ } catch (err) {
293
+ this.markError(err);
294
+ } finally {
295
+ this.releaseListeners();
296
+ }
297
+ }
298
+
288
299
  /** Increment tool use count. Called by record-observer on tool_execution_end. */
289
300
  incrementToolUses(): void {
290
301
  this._toolUses++;
@@ -431,11 +442,9 @@ export class Agent {
431
442
  this.releaseListeners();
432
443
 
433
444
  let finalResult = result.responseText;
434
- if (this.worktreeState && this._worktrees) {
435
- const wtResult = this.worktreeState.performCleanup(this._worktrees, this.description);
436
- if (wtResult.hasChanges && wtResult.branch) {
437
- finalResult += `\n\n---\nChanges saved to branch \`${wtResult.branch}\`. Merge with: \`git merge ${wtResult.branch}\``;
438
- }
445
+ const wtResult = this.worktree?.cleanup(this.description);
446
+ if (wtResult?.hasChanges && wtResult.branch) {
447
+ finalResult += `\n\n---\nChanges saved to branch \`${wtResult.branch}\`. Merge with: \`git merge ${wtResult.branch}\``;
439
448
  }
440
449
 
441
450
  if (result.aborted) this.markAborted(finalResult);
@@ -455,11 +464,9 @@ export class Agent {
455
464
  this.markError(err);
456
465
  this.releaseListeners();
457
466
 
458
- if (this.worktreeState && this._worktrees) {
459
- try {
460
- this.worktreeState.performCleanup(this._worktrees, this.description);
461
- } catch (cleanupErr) { debugLog("cleanupWorktree on agent error", cleanupErr); }
462
- }
467
+ try {
468
+ this.worktree?.cleanup(this.description);
469
+ } catch (cleanupErr) { debugLog("cleanupWorktree on agent error", cleanupErr); }
463
470
 
464
471
  this.observer?.onRunFinished?.(this);
465
472
  }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * worktree-isolation.ts — WorktreeIsolation: collaborator that owns the
3
+ * git-worktree lifecycle for an isolated agent.
4
+ *
5
+ * Constructed by AgentManager only when isolation === "worktree", bound to a
6
+ * WorktreeManager and the agent id. Agent tells it `setup()` and
7
+ * `cleanup(description)` instead of managing worktree internals itself.
8
+ *
9
+ * The presence/absence of this collaborator IS the isolation mode: Agent calls
10
+ * `this.worktree?.setup()` rather than checking an isolation string.
11
+ */
12
+
13
+ import type { WorktreeCleanupResult, WorktreeInfo, WorktreeManager } from "#src/lifecycle/worktree";
14
+
15
+ export class WorktreeIsolation {
16
+ private _info?: WorktreeInfo;
17
+ private _cleanupResult?: WorktreeCleanupResult;
18
+
19
+ constructor(
20
+ private readonly worktrees: WorktreeManager,
21
+ private readonly agentId: string,
22
+ ) {}
23
+
24
+ /** Absolute worktree path — undefined before setup(). */
25
+ get path(): string | undefined {
26
+ return this._info?.path;
27
+ }
28
+
29
+ /** Cleanup outcome — undefined until cleanup() runs. */
30
+ get cleanupResult(): WorktreeCleanupResult | undefined {
31
+ return this._cleanupResult;
32
+ }
33
+
34
+ /**
35
+ * Create the git worktree and store its info.
36
+ * Throws on failure (strict isolation — no silent fallback).
37
+ */
38
+ setup(): void {
39
+ const wt = this.worktrees.create(this.agentId);
40
+ if (!wt) {
41
+ throw new Error(
42
+ 'Cannot run with isolation: "worktree" — not a git repo, no commits yet, or `git worktree add` failed. ' +
43
+ "Initialize git and commit at least once, or omit `isolation`.",
44
+ );
45
+ }
46
+ this._info = wt;
47
+ }
48
+
49
+ /**
50
+ * Perform worktree cleanup and record the result.
51
+ * No-op returning { hasChanges: false } if setup never ran.
52
+ */
53
+ cleanup(description: string): WorktreeCleanupResult {
54
+ if (!this._info) return { hasChanges: false };
55
+ const result = this.worktrees.cleanup(this._info, description);
56
+ this._cleanupResult = result;
57
+ return result;
58
+ }
59
+ }
@@ -128,7 +128,7 @@ export function toSubagentRecord(record: Agent): SubagentRecord {
128
128
  if (record.result !== undefined) out.result = record.result;
129
129
  if (record.error !== undefined) out.error = record.error;
130
130
  if (record.completedAt !== undefined) out.completedAt = record.completedAt;
131
- const worktreeResult = record.worktreeState?.cleanupResult;
131
+ const worktreeResult = record.worktree?.cleanupResult;
132
132
  if (worktreeResult !== undefined) out.worktreeResult = worktreeResult;
133
133
 
134
134
  return out;
@@ -1,45 +0,0 @@
1
- /**
2
- * worktree-state.ts — WorktreeState: lifecycle-phase object for worktree-isolated agents.
3
- *
4
- * Constructed once when the worktree is set up (before the run begins).
5
- * Only exists for agents with isolation: "worktree".
6
- * cleanupResult is recorded once at completion or error — it is not set at construction.
7
- */
8
-
9
- import type { WorktreeCleanupResult, WorktreeInfo, WorktreeManager } from "#src/lifecycle/worktree";
10
-
11
- export type { WorktreeCleanupResult, WorktreeInfo };
12
-
13
- export class WorktreeState {
14
- /** Absolute path to the worktree directory. */
15
- readonly path: string;
16
- /** Branch name created for this worktree. */
17
- readonly branch: string;
18
-
19
- private _cleanupResult?: WorktreeCleanupResult;
20
-
21
- constructor(info: WorktreeInfo) {
22
- this.path = info.path;
23
- this.branch = info.branch;
24
- }
25
-
26
- /** Result of the worktree cleanup — undefined until recordCleanup is called. */
27
- get cleanupResult(): WorktreeCleanupResult | undefined {
28
- return this._cleanupResult;
29
- }
30
-
31
- /** Record the cleanup result. Called once on agent completion or error. */
32
- recordCleanup(result: WorktreeCleanupResult): void {
33
- this._cleanupResult = result;
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
- }
45
- }