@gotgenes/pi-subagents 16.1.0 → 16.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/dist/public.d.ts +19 -22
- package/docs/architecture/architecture.md +44 -12
- package/docs/plans/0373-extract-subagent-state.md +250 -0
- package/docs/plans/0403-abort-subagents-on-interrupt.md +180 -0
- package/docs/retro/0373-extract-subagent-state.md +94 -0
- package/docs/retro/0381-replace-concurrency-queue-with-limiter.md +46 -0
- package/docs/retro/0403-abort-subagents-on-interrupt.md +49 -0
- package/package.json +1 -1
- package/src/handlers/index.ts +1 -0
- package/src/handlers/interrupt.ts +49 -0
- package/src/index.ts +5 -1
- package/src/lifecycle/subagent-manager.ts +19 -16
- package/src/lifecycle/subagent-state.ts +156 -0
- package/src/lifecycle/subagent.ts +84 -162
- package/src/observation/record-observer.ts +15 -13
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
---
|
|
2
|
+
issue: 373
|
|
3
|
+
issue_title: "Extract SubagentState; make Subagent execution deps mandatory"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Retro: #373 — Extract SubagentState; make Subagent execution deps mandatory
|
|
7
|
+
|
|
8
|
+
## Stage: Planning (2026-06-14T03:34:51Z)
|
|
9
|
+
|
|
10
|
+
### Session summary
|
|
11
|
+
|
|
12
|
+
Produced the implementation plan at `packages/pi-subagents/docs/plans/0373-extract-subagent-state.md`.
|
|
13
|
+
The architecture doc (Phase 17 Step 2 + "First-principles refinement") already specified the design precisely and the issue body matched it, so planning was confirmation-and-detailing rather than discovery.
|
|
14
|
+
Issue is first-party (`gotgenes`) and unambiguous — skipped the `ask_user` gate.
|
|
15
|
+
|
|
16
|
+
### Observations
|
|
17
|
+
|
|
18
|
+
- **Not breaking** for the published surface: `src/service/service.ts` exposes `SubagentRecord`/`SubagentStatus`/spawn-config, never `SubagentInit` or the `Subagent` constructor.
|
|
19
|
+
Only the internal constructor signature changes.
|
|
20
|
+
- **Single production construction site** confirmed: `SubagentManager.spawn` (~line 139) is the only `new Subagent(...)` outside tests — this is what makes mandatory execution deps viable.
|
|
21
|
+
- **Observer retarget is required**, not optional: making execution mandatory would otherwise force `record-observer.test.ts` to stub execution.
|
|
22
|
+
Pointing `subscribeSubagentObserver` at `SubagentState` (and dropping the record from `onCompact`, closing over `this` in `subagent.ts`) is the move that lets observer tests target `SubagentState` directly.
|
|
23
|
+
- **`resume()`'s missing-session throw stays** — it guards a genuine runtime state, not a construction concern.
|
|
24
|
+
Only the two `run()` "not configured for execution" throws are deleted.
|
|
25
|
+
- **`SubagentStatus` home**: moved to `subagent-state.ts` but re-exported from `subagent.ts` to keep `service.ts`'s import path (and the public type bundle path) unchanged, and to avoid a circular import.
|
|
26
|
+
- **Lift-and-shift for the large test file**: `test/lifecycle/subagent.test.ts` (~700 LOC).
|
|
27
|
+
Step 1 funnels constructions through a local helper and moves the state-machine `describe` blocks to the new `subagent-state.test.ts`, so Step 3's mandatory-execution flip is bounded to the helper + two run/resume factories.
|
|
28
|
+
Step 3 is unavoidably one atomic commit (removing optional fields breaks every construction at the type level at once).
|
|
29
|
+
- **Doc updates identified**: `architecture.md` (lifecycle file listing, `Subagent` class diagram, mark Step 2 ✅ Complete, Phase 17 prose ~line 879, type-complexity table ~line 649) and `SKILL.md` (Lifecycle 10→11 modules, total 56→57 files).
|
|
30
|
+
- Deferred per scope boundary: metrics-as-projection and result-delivery domain extraction (the other two of the four conflated domains).
|
|
31
|
+
|
|
32
|
+
## Stage: Implementation — TDD (2026-06-14T09:23:00Z)
|
|
33
|
+
|
|
34
|
+
### Session summary
|
|
35
|
+
|
|
36
|
+
Executed all four planned steps as separate commits: (1) extract `SubagentState` value object + new `subagent-state.test.ts`, (2) retarget `subscribeSubagentObserver` at `SubagentState`, (3) the atomic flip making `SubagentExecution` a mandatory collaborator and deleting the two `run()` throws, (4) docs.
|
|
37
|
+
Test count moved 966 → 967 (net): +26 new `SubagentState` tests, minus the migrated state-machine duplicates and the obsolete missing-factory test.
|
|
38
|
+
Pre-completion reviewer returned **PASS**; `check`/`lint`/`test`/`fallow` all clean.
|
|
39
|
+
|
|
40
|
+
### Observations
|
|
41
|
+
|
|
42
|
+
- The plan held exactly — every file in Module-Level Changes was touched and nothing else.
|
|
43
|
+
The `createTestSubagent` consumers (`conversation-viewer`, `notification`, `get-result-tool`, `make-subagent.test`) stayed untouched as predicted; the helper absorbed the construction change via a `TestSubagentOptions` shape that splits passive-state shorthands from identity/execution.
|
|
44
|
+
- **Explicit-`undefined` preservation** (testing-skill warning) mattered: `createTestSubagent` and the local `makeSubagent` build their `SubagentState` via spread of the rest-captured state overrides (`{ defaults, ...stateOverrides }`) so callers passing `completedAt: undefined` (running-status records in `get-result-tool.test`) still get `undefined`, not the `2000` default.
|
|
45
|
+
- The lift-and-shift prep in Step 1 (local `makeSubagent` helper + perl-routing the single-line constructions) paid off: Step 3's breaking flip only had to edit the helper, `createRunnableAgent`, `createResumableAgent`, `createCompletionAgent`, and the constructor describe — not the whole file.
|
|
46
|
+
- Removed the obsolete "throws when the session factory is missing" test (the guard is gone by construction); the construct-complete invariant is now type-level, not runtime-testable.
|
|
47
|
+
An initial replacement comment was dropped per reviewer/operator feedback as unhelpful.
|
|
48
|
+
- `SubagentExecution` carries 12 fields (4 mandatory).
|
|
49
|
+
Reviewer flagged it as wide but accepted per the plan's recorded decision to keep it concrete rather than split further.
|
|
50
|
+
- Pre-completion reviewer: **PASS** (no WARN findings).
|
|
51
|
+
|
|
52
|
+
## Stage: Final Retrospective (2026-06-14T17:20:00Z)
|
|
53
|
+
|
|
54
|
+
### Session summary
|
|
55
|
+
|
|
56
|
+
Shipped #373 end-to-end across one conversation spanning Planning → TDD → Ship → Retro: four implementation commits, CI green, issue closed, no release-please PR (a `refactor:`-only change does not trigger a release).
|
|
57
|
+
The plan held exactly — zero rework, and the pre-completion reviewer returned PASS with nothing to fix.
|
|
58
|
+
The single user intervention was a one-line comment removal during TDD Step 3.
|
|
59
|
+
|
|
60
|
+
### Observations
|
|
61
|
+
|
|
62
|
+
#### What went well
|
|
63
|
+
|
|
64
|
+
- **Plan-to-ship with zero rework.**
|
|
65
|
+
Every file in the plan's Module-Level Changes was touched and nothing else; the `createTestSubagent` consumers stayed untouched exactly as predicted.
|
|
66
|
+
The lift-and-shift prep (Step 1 funneling constructions through a local `makeSubagent` helper) bounded the breaking Step 3 flip to the helper plus three factories — the atomic-construction-change concern from the plan never materialized as churn.
|
|
67
|
+
- **Clean model allocation across stages.**
|
|
68
|
+
Planning ran on `claude-opus-4-8`, TDD on `claude-sonnet-4-6`, Ship on `opencode-go/deepseek-v4-flash` (mechanical git/CI/close work), the pre-completion reviewer subagent on `claude-sonnet-4-6`, and Retro on `claude-opus-4-8`.
|
|
69
|
+
Judgment-heavy work landed on reasoning-strong models; the cheap model handled only the mechanical ship sequence.
|
|
70
|
+
- **Incremental verification.** `pnpm run check` ran after every TDD step (not just at the end), catching the shared-type breakage at the right boundary; the affected test files were run per-step before the full suite.
|
|
71
|
+
|
|
72
|
+
#### What caused friction (agent side)
|
|
73
|
+
|
|
74
|
+
- `other` (tombstone comment) — after removing the obsolete "throws when the session factory is missing" test in TDD Step 3, left a comment narrating the *absence* of the guard (`// No "missing session factory" guard: execution is a mandatory constructor collaborator …`).
|
|
75
|
+
The user flagged it as unhelpful and asked for removal.
|
|
76
|
+
Impact: one extra `Edit` + a blank-line cleanup + a `--amend` of the Step 3 commit.
|
|
77
|
+
No behavioral rework; user-caught.
|
|
78
|
+
|
|
79
|
+
#### What caused friction (user side)
|
|
80
|
+
|
|
81
|
+
- None of consequence.
|
|
82
|
+
The single intervention (comment removal) was light mechanical oversight on an otherwise self-driving session; no earlier context would have changed the outcome.
|
|
83
|
+
|
|
84
|
+
### Diagnostic details
|
|
85
|
+
|
|
86
|
+
- **Model-performance correlation** — no mismatch.
|
|
87
|
+
The only subagent dispatch (pre-completion-reviewer) ran on `claude-sonnet-4-6`, appropriate for judgment-heavy review; it returned PASS.
|
|
88
|
+
The Ship stage on `deepseek-v4-flash` was purely mechanical (git push, `ci_find`/`ci_watch`, `issue_close`, `release_pr_find`) and the one judgment point (the batch-vs-release `ask_user`) was handled correctly.
|
|
89
|
+
- **Escalation-delay / unused-tool / feedback-loop** — nothing notable: no rabbit-holes, no error-chasing sequences, and verification ran incrementally throughout.
|
|
90
|
+
Lenses skipped.
|
|
91
|
+
|
|
92
|
+
### Changes made
|
|
93
|
+
|
|
94
|
+
1. `.pi/skills/code-design/SKILL.md` (§ Names over comments) — added a line forbidding tombstone comments that narrate removed code or the absence of a guard/test/branch, prompted by the user-caught over-comment in TDD Step 3.
|
|
@@ -46,4 +46,50 @@ Test count went 975 → 966 (−22 deleted queue tests, +13 new limiter tests);
|
|
|
46
46
|
- Pre-completion reviewer: WARN (no FAILs).
|
|
47
47
|
Reviewer warnings: the single stale-comment finding at `index.ts:125` — now fixed in commit `90135005`.
|
|
48
48
|
|
|
49
|
+
## Stage: Final Retrospective (2026-06-14T00:30:00Z)
|
|
50
|
+
|
|
51
|
+
### Session summary
|
|
52
|
+
|
|
53
|
+
Shipped #381 across planning, TDD, and release: `pi-subagents` `16.0.0` → `16.1.0`, tag `pi-subagents-v16.1.0`.
|
|
54
|
+
Four commits landed (one `feat`, two `refactor`, one `docs`) plus two `docs(retro)` notes; CI passed first try, the issue was closed with an implemented-in summary, and the release-please PR was merged.
|
|
55
|
+
The plan — written down to code sketches — held up across all three TDD cycles with no design rework.
|
|
56
|
+
|
|
57
|
+
### Observations
|
|
58
|
+
|
|
59
|
+
#### What went well
|
|
60
|
+
|
|
61
|
+
- The plan's fidelity paid off: the `clear()`-settles-pending-promises decision, the atomic step-2 sequencing (migrate consumers + delete queue + delete old test in one commit), and the `void`-prefix prediction for floating promises were all made at planning time and executed without surprise.
|
|
62
|
+
The `queueing and concurrency` manager tests passed unchanged after only the `createManager` helper swap, validating the planning claim that they exercise behavior, not queue internals.
|
|
63
|
+
- The pre-completion-reviewer (on `anthropic/claude-sonnet-4-6`, 161s, 21 tool uses) caught a stale comment at `src/index.ts:125` that all four deterministic gates (`check`, `lint`, `test`, `fallow dead-code`) passed over.
|
|
64
|
+
This is the backstop working exactly as intended — a judgment-model review surfacing residue that pattern-matchers cannot.
|
|
65
|
+
- Verification cadence was incremental, not end-loaded: file-scoped `vitest` + `biome` + `eslint` after step 1, `pnpm run check` immediately after the shared-interface change mid-step-2 (per the plan's own instruction), then lifecycle suite → full suite → full lint, then `rumdl` for the docs step, then the full gates + `fallow` before push.
|
|
66
|
+
|
|
67
|
+
#### What caused friction (agent side)
|
|
68
|
+
|
|
69
|
+
- `missing-context` (self/reviewer-caught) — the stale comment `// before startAgent / queue drain` at `src/index.ts:125` referenced two deleted concepts but was not cataloged in the plan's Module-Level Changes, despite the planning grep output having surfaced that exact line.
|
|
70
|
+
The grep hit was visible but never converted into a plan action or an explicit leave-as-is.
|
|
71
|
+
Impact: one small follow-up commit (`90135005`, `refactor:`); no rework, no design impact — the reviewer backstop absorbed it before ship.
|
|
72
|
+
|
|
73
|
+
#### What caused friction (user side)
|
|
74
|
+
|
|
75
|
+
- None.
|
|
76
|
+
The single user touchpoint — the release-timing gate in `/ship-issue` (release now vs. batch the Phase 17 sequence) — was strategic judgment the agent correctly deferred, not mechanical oversight.
|
|
77
|
+
|
|
78
|
+
### Diagnostic details
|
|
79
|
+
|
|
80
|
+
- **Model-performance correlation** — one subagent dispatch (`pre-completion-reviewer`) on `anthropic/claude-sonnet-4-6`; appropriate match for judgment-heavy review, and it returned the session's only actionable finding.
|
|
81
|
+
- **Escalation-delay tracking** — no rabbit-holes; the lone lint error (`@typescript-eslint/no-floating-promises`, 18 sites) was resolved in a single test-file rewrite, far under the 5-call escalation threshold.
|
|
82
|
+
- **Unused-tool detection** — nothing under-tooled; `colgrep`/`grep` were used during planning exploration and the reviewer subagent was dispatched as designed.
|
|
83
|
+
- **Feedback-loop gap analysis** — no gap; verification ran after every cycle, with `pnpm run check` correctly invoked right after the shared-interface change rather than at end-of-session.
|
|
84
|
+
|
|
85
|
+
#### Process note (no inline change)
|
|
86
|
+
|
|
87
|
+
- The release-please PR merge required the documented `UNSTABLE` → `gh pr merge` fallback (step 6.4 of `/ship-issue`) because default-`GITHUB_TOKEN` release PRs never get checks.
|
|
88
|
+
This recurs every release; the prompt already handles it, so it is recorded here only as a standing pattern, not a friction point.
|
|
89
|
+
|
|
90
|
+
### Changes made
|
|
91
|
+
|
|
92
|
+
1. Added this Final Retrospective stage entry to `packages/pi-subagents/docs/retro/0381-replace-concurrency-queue-with-limiter.md`.
|
|
93
|
+
2. No prompt or `AGENTS.md` changes — the operator chose retro-file-only, since the single friction (the stale `src/index.ts:125` comment) was a one-off execution slip already caught by the pre-completion-reviewer backstop, and the candidate grep-hit rule was judged not worth the prompt verbosity.
|
|
94
|
+
|
|
49
95
|
[#378]: https://github.com/gotgenes/pi-packages/issues/378
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
---
|
|
2
|
+
issue: 403
|
|
3
|
+
issue_title: "Pressing Escape does not stop subagent/background agent"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Retro: #403 — Pressing Escape does not stop subagent/background agent
|
|
7
|
+
|
|
8
|
+
## Stage: Planning (2026-06-14T00:00:00Z)
|
|
9
|
+
|
|
10
|
+
### Session summary
|
|
11
|
+
|
|
12
|
+
Investigated the third-party bug report that ESC does not stop subagents and traced the abort path through both the package and the pinned Pi SDK peer deps.
|
|
13
|
+
Found that foreground subagents already receive the parent abort signal end-to-end, while background subagents are detached with no interrupt wiring — the reproducible bug.
|
|
14
|
+
Confirmed direction with the operator via `ask_user` (third-party gate): implement ESC-to-abort for both modes, with a foreground guard test, aborting all running and queued background agents.
|
|
15
|
+
Wrote and committed plan `0403-abort-subagents-on-interrupt.md`.
|
|
16
|
+
|
|
17
|
+
### Observations
|
|
18
|
+
|
|
19
|
+
- Key SDK fact that de-risks the design: in `pi-agent-core` `agent.js`, each run creates a fresh `AbortController` and `finishRun()` discards it **without** aborting on normal completion.
|
|
20
|
+
So the parent signal's `abort` event fires only on a real ESC interrupt — latching `abortAll()` to it will not spuriously kill background agents at turn end.
|
|
21
|
+
- Chosen mechanism: a small `InterruptHandler` driven by `pi.on("turn_start", ...)`, re-latching `ctx.signal` each turn so the latch tracks the live per-run signal even across runs and tool-less turns.
|
|
22
|
+
`turn_start` was preferred over `tool_execution_start` because a background agent can outlive the run that spawned it; a turn-level latch still holds the current run's signal when the user interrupts a later tool-less turn.
|
|
23
|
+
- Reused the existing `manager.abortAll()` rather than adding `abortBackground()`.
|
|
24
|
+
Foreground agents are already aborted via their own `wireSignal`, so `abortAll()`'s overlap is redundant-but-harmless (status-guarded `abort()`, idempotent `markStopped`).
|
|
25
|
+
The manager does not store `isBackground` on the record, so distinguishing modes would need extra state — deferred as an Open Question.
|
|
26
|
+
- Classified as a non-breaking `fix:` (not `fix!:`): no config key, default, or output shape changes; detached-survives-ESC was a limitation, not a contract.
|
|
27
|
+
Noted the behavior change explicitly in Goals.
|
|
28
|
+
- Foreground path is believed already-correct from the code trace; the plan adds a regression guard in `subagent-session.test.ts` (`forwardAbortSignal` is currently untested for the parent-signal path) and will fix only if the guard fails.
|
|
29
|
+
|
|
30
|
+
## Stage: Implementation — TDD (2026-06-14T18:00:00Z)
|
|
31
|
+
|
|
32
|
+
### Session summary
|
|
33
|
+
|
|
34
|
+
Completed all three TDD cycles against a green baseline (967 tests).
|
|
35
|
+
Added the foreground-abort guard, implemented `InterruptHandler` + `turn_start` wiring, and updated the architecture doc.
|
|
36
|
+
Test count went from 967 to 975 (+8: 6 `InterruptHandler` unit tests, 2 foreground guard tests); `check`, `lint`, `test`, and `fallow dead-code` all pass.
|
|
37
|
+
|
|
38
|
+
### Observations
|
|
39
|
+
|
|
40
|
+
- The foreground guard (Step 1) passed on the first run, confirming the planning-stage code trace: the parent signal already reaches the child `session.abort()` via `forwardAbortSignal`.
|
|
41
|
+
No code fix was needed, so it landed as `test:` exactly as the plan anticipated.
|
|
42
|
+
- `InterruptHandler` came out clean against the `code-design` heuristics — one field read from `ctx`, one method on a one-method `InterruptManager` interface, latch state owned internally, `{ once: true }` listener.
|
|
43
|
+
The reviewer's code-design check was PASS with no structural concerns.
|
|
44
|
+
- `abortAll()` gained a second narrow-interface consumer (the new handler) on top of the shutdown path; `fallow dead-code` stayed green, so its existing `fallow-ignore-next-line unused-class-member` comment was left untouched.
|
|
45
|
+
- Pre-completion reviewer: **WARN**.
|
|
46
|
+
- Reviewer warnings: stale source-file counts in `architecture.md`.
|
|
47
|
+
Fixed the current-state prose claim (`56` → `58` source files).
|
|
48
|
+
Left the fallow health-metrics snapshot rows (line ~650, `7,778 (57 files)`) intact — those are point-in-time analysis tables where the file count was computed alongside LOC and other metrics, so bumping one cell in isolation would desync the snapshot.
|
|
49
|
+
Amended the fix into the docs commit (not yet pushed).
|
package/package.json
CHANGED
package/src/handlers/index.ts
CHANGED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* turn_start event handler that aborts subagents on a parent interrupt (ESC).
|
|
3
|
+
*
|
|
4
|
+
* The parent agent loop creates a fresh AbortController per run and only aborts
|
|
5
|
+
* it on an explicit interrupt — never on normal completion. So latching to the
|
|
6
|
+
* current run's signal and aborting on its `abort` event fires exactly on ESC.
|
|
7
|
+
*
|
|
8
|
+
* `turn_start` carries the live per-run `ctx.signal`, so re-latching each turn
|
|
9
|
+
* keeps the handler tracking the current signal across runs and tool-less turns.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Narrow manager interface — only the method the interrupt handler calls. */
|
|
13
|
+
export interface InterruptManager {
|
|
14
|
+
abortAll(): number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Minimal context shape — only the field the handler reads. */
|
|
18
|
+
interface InterruptCtx {
|
|
19
|
+
signal: AbortSignal | undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Latches the current parent abort signal and aborts all subagents when it fires.
|
|
24
|
+
*
|
|
25
|
+
* The latch dedups by reference: most turns reuse the same signal (no-op); a new
|
|
26
|
+
* run's signal triggers a detach-and-rewire. The `abort` listener is one-shot.
|
|
27
|
+
*/
|
|
28
|
+
export class InterruptHandler {
|
|
29
|
+
private latched?: AbortSignal;
|
|
30
|
+
private detach?: () => void;
|
|
31
|
+
|
|
32
|
+
constructor(private readonly manager: InterruptManager) {}
|
|
33
|
+
|
|
34
|
+
handleTurnStart(ctx: InterruptCtx): void {
|
|
35
|
+
const signal = ctx.signal;
|
|
36
|
+
if (signal === this.latched) return;
|
|
37
|
+
|
|
38
|
+
this.detach?.();
|
|
39
|
+
this.detach = undefined;
|
|
40
|
+
this.latched = signal;
|
|
41
|
+
if (!signal) return;
|
|
42
|
+
|
|
43
|
+
const onAbort = (): void => {
|
|
44
|
+
this.manager.abortAll();
|
|
45
|
+
};
|
|
46
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
47
|
+
this.detach = () => signal.removeEventListener("abort", onAbort);
|
|
48
|
+
}
|
|
49
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -22,7 +22,7 @@ import {
|
|
|
22
22
|
} from "@earendil-works/pi-coding-agent";
|
|
23
23
|
import { AgentTypeRegistry } from "#src/config/agent-types";
|
|
24
24
|
import { loadCustomAgents } from "#src/config/custom-agents";
|
|
25
|
-
import { SessionLifecycleHandler, ToolStartHandler } from "#src/handlers/index";
|
|
25
|
+
import { InterruptHandler, SessionLifecycleHandler, ToolStartHandler } from "#src/handlers/index";
|
|
26
26
|
import { createChildLifecyclePublisher } from "#src/lifecycle/child-lifecycle";
|
|
27
27
|
import { ConcurrencyLimiter } from "#src/lifecycle/concurrency-limiter";
|
|
28
28
|
import { createSubagentSession, type SubagentSessionDeps } from "#src/lifecycle/create-subagent-session";
|
|
@@ -185,6 +185,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
185
185
|
const toolStart = new ToolStartHandler(runtime);
|
|
186
186
|
pi.on("tool_execution_start", (event, ctx) => toolStart.handleToolExecutionStart(event, ctx));
|
|
187
187
|
|
|
188
|
+
// Abort all subagents when the parent agent loop is interrupted (ESC).
|
|
189
|
+
const interrupt = new InterruptHandler(manager);
|
|
190
|
+
pi.on("turn_start", (_event, ctx) => interrupt.handleTurnStart(ctx));
|
|
191
|
+
|
|
188
192
|
// ---- Agent tool ----
|
|
189
193
|
|
|
190
194
|
pi.registerTool(new AgentTool(manager, runtime, settings, registry, getAgentDir()).toToolDefinition());
|
|
@@ -14,6 +14,7 @@ import type { CreateSubagentSessionParams } from "#src/lifecycle/create-subagent
|
|
|
14
14
|
import type { ParentSnapshot } from "#src/lifecycle/parent-snapshot";
|
|
15
15
|
import { Subagent, type SubagentLifecycleObserver } from "#src/lifecycle/subagent";
|
|
16
16
|
import type { SubagentSession } from "#src/lifecycle/subagent-session";
|
|
17
|
+
import { SubagentState } from "#src/lifecycle/subagent-state";
|
|
17
18
|
import type { WorkspaceProvider } from "#src/lifecycle/workspace";
|
|
18
19
|
|
|
19
20
|
import type { RunConfig } from "#src/runtime";
|
|
@@ -140,23 +141,25 @@ export class SubagentManager {
|
|
|
140
141
|
id,
|
|
141
142
|
type,
|
|
142
143
|
description: options.description,
|
|
143
|
-
status: options.isBackground ? "queued" : "running",
|
|
144
|
-
startedAt: Date.now(),
|
|
145
144
|
invocation: options.invocation,
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
145
|
+
state: new SubagentState({
|
|
146
|
+
status: options.isBackground ? "queued" : "running",
|
|
147
|
+
startedAt: Date.now(),
|
|
148
|
+
}),
|
|
149
|
+
execution: {
|
|
150
|
+
createSubagentSession: this.createSubagentSession,
|
|
151
|
+
snapshot,
|
|
152
|
+
prompt,
|
|
153
|
+
baseCwd: this.baseCwd,
|
|
154
|
+
observer: this.buildObserver(options),
|
|
155
|
+
getRunConfig: this.getRunConfig,
|
|
156
|
+
getWorkspaceProvider: () => this._workspaceProvider,
|
|
157
|
+
model: options.model,
|
|
158
|
+
maxTurns: options.maxTurns,
|
|
159
|
+
thinkingLevel: options.thinkingLevel,
|
|
160
|
+
parentSession: options.parentSession,
|
|
161
|
+
signal: options.signal,
|
|
162
|
+
},
|
|
160
163
|
});
|
|
161
164
|
this.agents.set(id, record);
|
|
162
165
|
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* subagent-state.ts — SubagentState value object: lifecycle status and metrics.
|
|
3
|
+
*
|
|
4
|
+
* Owns the passive, readable state of a subagent — status, result, error,
|
|
5
|
+
* timestamps, and stats (toolUses, lifetimeUsage, compactionCount) — together
|
|
6
|
+
* with the transition methods (markRunning, markCompleted, …) and accumulation
|
|
7
|
+
* methods (incrementToolUses, addUsage, incrementCompactions) that mutate it.
|
|
8
|
+
*
|
|
9
|
+
* State is encapsulated behind getters; external code reads through them but
|
|
10
|
+
* mutates only via the transition/accumulation methods. The value object owns
|
|
11
|
+
* all of its own mutations — no field is written from outside.
|
|
12
|
+
*
|
|
13
|
+
* Subagent holds one of these privately and delegates its getters and mutation
|
|
14
|
+
* methods to it. Extracting it lets the lifecycle state machine and the
|
|
15
|
+
* session-event observer be unit-tested without constructing an executor.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { LifetimeUsage } from "#src/lifecycle/usage";
|
|
19
|
+
import { addUsage } from "#src/lifecycle/usage";
|
|
20
|
+
|
|
21
|
+
export type SubagentStatus =
|
|
22
|
+
| "queued"
|
|
23
|
+
| "running"
|
|
24
|
+
| "completed"
|
|
25
|
+
| "steered"
|
|
26
|
+
| "aborted"
|
|
27
|
+
| "stopped"
|
|
28
|
+
| "error";
|
|
29
|
+
|
|
30
|
+
export interface SubagentStateInit {
|
|
31
|
+
status?: SubagentStatus;
|
|
32
|
+
result?: string;
|
|
33
|
+
error?: string;
|
|
34
|
+
startedAt?: number;
|
|
35
|
+
completedAt?: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export class SubagentState {
|
|
39
|
+
// Transition state — encapsulated behind getters, mutated only via transition methods
|
|
40
|
+
private _status: SubagentStatus;
|
|
41
|
+
get status(): SubagentStatus { return this._status; }
|
|
42
|
+
|
|
43
|
+
private _result?: string;
|
|
44
|
+
get result(): string | undefined { return this._result; }
|
|
45
|
+
|
|
46
|
+
private _error?: string;
|
|
47
|
+
get error(): string | undefined { return this._error; }
|
|
48
|
+
|
|
49
|
+
private _startedAt: number;
|
|
50
|
+
get startedAt(): number { return this._startedAt; }
|
|
51
|
+
|
|
52
|
+
private _completedAt?: number;
|
|
53
|
+
get completedAt(): number | undefined { return this._completedAt; }
|
|
54
|
+
|
|
55
|
+
// Stats — accumulated via mutation methods, readable via getters
|
|
56
|
+
private _toolUses = 0;
|
|
57
|
+
get toolUses(): number { return this._toolUses; }
|
|
58
|
+
|
|
59
|
+
private _lifetimeUsage: LifetimeUsage = { input: 0, output: 0, cacheWrite: 0 };
|
|
60
|
+
get lifetimeUsage(): Readonly<LifetimeUsage> { return this._lifetimeUsage; }
|
|
61
|
+
|
|
62
|
+
private _compactionCount = 0;
|
|
63
|
+
get compactionCount(): number { return this._compactionCount; }
|
|
64
|
+
|
|
65
|
+
constructor(init: SubagentStateInit = {}) {
|
|
66
|
+
this._status = init.status ?? "queued";
|
|
67
|
+
this._result = init.result;
|
|
68
|
+
this._error = init.error;
|
|
69
|
+
this._startedAt = init.startedAt ?? Date.now();
|
|
70
|
+
this._completedAt = init.completedAt;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Increment tool use count. Called by record-observer on tool_execution_end. */
|
|
74
|
+
incrementToolUses(): void {
|
|
75
|
+
this._toolUses++;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Accumulate a usage delta into lifetimeUsage. Called by record-observer on message_end. */
|
|
79
|
+
addUsage(delta: { input: number; output: number; cacheWrite: number }): void {
|
|
80
|
+
addUsage(this._lifetimeUsage, delta);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Increment compaction count. Called by record-observer on compaction_end. */
|
|
84
|
+
incrementCompactions(): void {
|
|
85
|
+
this._compactionCount++;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Transition to running state. Sets status and startedAt. */
|
|
89
|
+
markRunning(startedAt: number): void {
|
|
90
|
+
this._status = "running";
|
|
91
|
+
this._startedAt = startedAt;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Transition to completed state.
|
|
96
|
+
* Always sets result and completedAt (??=). Only changes status if not stopped.
|
|
97
|
+
*/
|
|
98
|
+
markCompleted(result: string, completedAt?: number): void {
|
|
99
|
+
this._result = result;
|
|
100
|
+
this._completedAt ??= completedAt ?? Date.now();
|
|
101
|
+
if (this._status !== "stopped") {
|
|
102
|
+
this._status = "completed";
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Transition to aborted state.
|
|
108
|
+
* Always sets result and completedAt (??=). Only changes status if not stopped.
|
|
109
|
+
*/
|
|
110
|
+
markAborted(result: string, completedAt?: number): void {
|
|
111
|
+
this._result = result;
|
|
112
|
+
this._completedAt ??= completedAt ?? Date.now();
|
|
113
|
+
if (this._status !== "stopped") {
|
|
114
|
+
this._status = "aborted";
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Transition to steered state.
|
|
120
|
+
* Always sets result and completedAt (??=). Only changes status if not stopped.
|
|
121
|
+
*/
|
|
122
|
+
markSteered(result: string, completedAt?: number): void {
|
|
123
|
+
this._result = result;
|
|
124
|
+
this._completedAt ??= completedAt ?? Date.now();
|
|
125
|
+
if (this._status !== "stopped") {
|
|
126
|
+
this._status = "steered";
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Transition to error state.
|
|
132
|
+
* Always sets error (formatted) and completedAt (??=). Only changes status if not stopped.
|
|
133
|
+
*/
|
|
134
|
+
markError(error: unknown, completedAt?: number): void {
|
|
135
|
+
this._error = error instanceof Error ? error.message : String(error);
|
|
136
|
+
this._completedAt ??= completedAt ?? Date.now();
|
|
137
|
+
if (this._status !== "stopped") {
|
|
138
|
+
this._status = "error";
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Transition to stopped state. Always valid — no guard. */
|
|
143
|
+
markStopped(completedAt?: number): void {
|
|
144
|
+
this._status = "stopped";
|
|
145
|
+
this._completedAt = completedAt ?? Date.now();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Reset for resume: running status, new startedAt, clear completedAt/result/error. */
|
|
149
|
+
resetForResume(startedAt: number): void {
|
|
150
|
+
this._status = "running";
|
|
151
|
+
this._startedAt = startedAt;
|
|
152
|
+
this._completedAt = undefined;
|
|
153
|
+
this._result = undefined;
|
|
154
|
+
this._error = undefined;
|
|
155
|
+
}
|
|
156
|
+
}
|