@ferris1225/pi-subagents 4.1.8 → 4.1.9

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/src/prompt.ts CHANGED
@@ -61,21 +61,21 @@ export function buildDelegationDirective(
61
61
  : []),
62
62
  ...(hasCleaner
63
63
  ? [
64
- `Use \`cleaner\` only as the separate evidence-first entry for user-authorized cleanup, removal, simplification, duplicate-code consolidation, or maintenance; never substitute it for \`worker\`. It applies every safe proven in-scope cut without item-by-item approval. Generic or read-only audit, review, code-health, plan, or cleanup-candidate assessment goes to ${hasReviewer ? "`reviewer`" : "direct main-context inspection because `reviewer` is disabled"}. Never dispatch cleaner by PR count or as the pre-commit gate.`,
64
+ `Use \`cleaner\` only for user-authorized cleanup, removal, simplification, or duplicate-code consolidation; it applies every safe proven in-scope cut without item-by-item approval. Read-only audits and code-health reviews go to ${hasReviewer ? "`reviewer`" : "the main context because `reviewer` is disabled"}. Never dispatch cleaner by PR count or as the pre-commit gate.`,
65
65
  ]
66
66
  : []),
67
67
  ...(hasDocumenter
68
68
  ? [
69
- `Use \`documenter\` directly only for explicit whole-codebase maintenance or standalone documentation/comment work; a top-level documenter delivers directly without an automatic reviewer.${codeWriterNames.length > 0 ? ` ${codeWriterNames.join("/")} must sync existing docs they directly affect; runtime runs documenter only after REVIEW_PASS with DOCUMENTATION: NEEDED or a missing marker, or as the reviewer-disabled fallback—never dispatch a duplicate.` : ""} It never changes runtime behavior, versions, or release state.`,
69
+ `Use \`documenter\` directly only for explicit whole-codebase or standalone documentation/comment work; a top-level documenter delivers without an automatic reviewer.${codeWriterNames.length > 0 ? ` ${codeWriterNames.join("/")} must sync existing docs they directly affect; runtime runs documenter only after REVIEW_PASS with DOCUMENTATION: NEEDED or a missing marker, or as the reviewer-disabled fallback—never dispatch a duplicate.` : ""} It never changes runtime behavior, versions, or release state.`,
70
70
  ]
71
71
  : []),
72
72
  ...(hasReviewer
73
73
  ? [
74
- `Use \`reviewer\` for read-only assessments or a gate.${reviewedWriterNames.length > 0 ? ` Successful ${reviewedWriterNames.join("/")} runs already get one fresh read-only reviewer gate, independent of the writer.` : ""} Advisory output has no VERDICT and cannot authorize follow-up edits${hasDocumenter ? "; gates classify docs separately for the enabled documenter." : "."}`,
74
+ `Use \`reviewer\` for read-only assessments or a gate.${reviewedWriterNames.length > 0 ? ` Successful ${reviewedWriterNames.join("/")} runs already get one fresh read-only reviewer gate, independent of the writer.` : ""} Advisory output has no VERDICT and cannot authorize follow-up edits${hasDocumenter ? "; gates classify docs separately for the enabled documenter." : "."} Re-verifying your own fixes? Dispatch with \`advisory: true\`: the report returns to you and never starts the auto-fix chain.`,
75
75
  ]
76
76
  : []),
77
77
  "Brief each child with the complete goal, exact paths, constraints, and expected output; it has no conversation memory.",
78
- "Children are leaf processes without delegation tools; use `subagent_control fork` on a parked/settled thread for an independent continuation.",
78
+ "Children are leaf processes without delegation tools; use `subagent_control resume` on a parked/settled thread to continue its retained context.",
79
79
  ...(hasMultiple
80
80
  ? [
81
81
  "Dispatch independent work in one `tasks` array and let the resumed main agent start dependent work only after prerequisites finish.",
package/src/rpc-run.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /*
2
2
  * Persistent pi RPC child transport for one logical sub-agent generation.
3
3
  *
4
- * A child stays alive across prompt/steer/abort/retarget operations and speaks
4
+ * A child stays alive across prompt/abort operations and speaks
5
5
  * strict LF-delimited JSONL. The process is terminated only after the logical
6
6
  * run settles, is parked/stopped, or fails. Session files remain owned by the
7
7
  * parent runtime so a later generation can resume the same thread.
@@ -16,6 +16,7 @@ import { StringDecoder } from "node:string_decoder";
16
16
  import type { Message } from "@earendil-works/pi-ai";
17
17
  import { SUBAGENT_TOOL_NAMES, type AgentConfig } from "./agents.ts";
18
18
  import type { ThinkingLevel } from "./config.ts";
19
+ import { writeTempOwnerMarker } from "./temp-hygiene.ts";
19
20
  import type { IsolationMode, WorktreeFinalizationStatus } from "./worktree.ts";
20
21
 
21
22
  export const DEPTH_ENV_VAR = "PI_SUBAGENT_DEPTH";
@@ -82,8 +83,6 @@ export interface RpcSingleResult {
82
83
  sessionDir?: string;
83
84
  /** Original task/project cwd used for result-artifact retention buckets. */
84
85
  projectCwd?: string;
85
- /** Internal disposition: dispatch suppresses completion delivery for parks. */
86
- parked?: boolean;
87
86
  /** Stable logical run id assigned by dispatch (also present on queued results). */
88
87
  runId?: number;
89
88
  /** Filesystem isolation selected for this logical thread. */
@@ -95,15 +94,13 @@ export interface RpcSingleResult {
95
94
  /** Retained only when integration/cleanup failed; never contains patch data. */
96
95
  integrationWorktreePath?: string;
97
96
  integrationPatchPath?: string;
98
- /** Session-fork relationships between stable logical run ids. */
99
- forkedFromRunId?: number;
100
- forkChildRunIds?: number[];
101
97
  }
102
98
 
103
99
  export type SubagentLiveEvent =
104
- | { kind: "status"; status: "queued" | "running" | "steering" | "interrupting" | "parked" | "done" | "failed" }
100
+ | { kind: "status"; status: "queued" | "running" | "interrupting" | "done" | "failed" }
105
101
  | { kind: "model"; model?: string; thinking?: ThinkingLevel; fallbackFrom?: string }
106
102
  | { kind: "usage"; usage: UsageStats; model?: string }
103
+ | { kind: "session"; sessionId: string; sessionDir: string }
107
104
  | { kind: "tool_start"; toolCallId?: string; toolName: string; args: unknown }
108
105
  | { kind: "tool_end"; toolCallId?: string; toolName: string; isError: boolean }
109
106
  | { kind: "thinking" }
@@ -113,17 +110,12 @@ export type RpcControlPhase =
113
110
  | "queued"
114
111
  | "starting"
115
112
  | "running"
116
- | "steering"
117
113
  | "interrupting"
118
114
  | "retrying"
119
- | "parked"
120
115
  | "settled"
121
116
  | "stopped";
122
117
 
123
118
  interface AttemptControl {
124
- steer(instruction: string): Promise<void>;
125
- retarget(objective: string): Promise<void>;
126
- park(): Promise<void>;
127
119
  stop(reason?: string): Promise<void>;
128
120
  }
129
121
 
@@ -138,9 +130,9 @@ export class RpcRunControl {
138
130
  private attempt?: { token: number; control: AttemptControl };
139
131
  private nextToken = 1;
140
132
  private serial: Promise<void> = Promise.resolve();
141
- private parkRequested = false;
142
133
  private stopRequested = false;
143
134
  private stopMessage = "Subagent was aborted";
135
+ private childPids = new Set<number>();
144
136
 
145
137
  constructor(
146
138
  objective: string,
@@ -158,10 +150,6 @@ export class RpcRunControl {
158
150
  return this.phase;
159
151
  }
160
152
 
161
- isParkRequested(): boolean {
162
- return this.parkRequested;
163
- }
164
-
165
153
  isStopRequested(): boolean {
166
154
  return this.stopRequested;
167
155
  }
@@ -170,15 +158,15 @@ export class RpcRunControl {
170
158
  return this.stopMessage;
171
159
  }
172
160
 
173
- /** Update a not-yet-started/retrying objective without launching a process. */
174
- retargetPending(objective: string): void {
175
- this.objective = objective;
161
+ /** Pids of every child process this generation spawned. Persisted with the
162
+ * thread record so a later load can kill orphans that still hold the
163
+ * retained session. */
164
+ noteChildPid(pid: number): void {
165
+ if (Number.isInteger(pid) && pid > 0) this.childPids.add(pid);
176
166
  }
177
167
 
178
- /** Mark queued/starting work for park without waiting on an RPC abort event. */
179
- parkPending(): void {
180
- this.parkRequested = true;
181
- this.setPhase("parked");
168
+ getChildPids(): number[] {
169
+ return [...this.childPids];
182
170
  }
183
171
 
184
172
  markStarting(): void {
@@ -186,12 +174,12 @@ export class RpcRunControl {
186
174
  }
187
175
 
188
176
  markRetrying(): void {
189
- if (!this.parkRequested && !this.stopRequested) this.setPhase("retrying");
177
+ if (!this.stopRequested) this.setPhase("retrying");
190
178
  }
191
179
 
192
180
  markSettled(): void {
193
181
  this.attempt = undefined;
194
- if (!this.parkRequested && !this.stopRequested) this.setPhase("settled");
182
+ if (!this.stopRequested) this.setPhase("settled");
195
183
  }
196
184
 
197
185
  /** Allocate an attempt token used to reject state updates from old children. */
@@ -212,32 +200,6 @@ export class RpcRunControl {
212
200
  this.setPhase(phase);
213
201
  }
214
202
 
215
- async steer(instruction: string): Promise<void> {
216
- return this.serialize(async () => {
217
- const attempt = this.attempt?.control;
218
- if (!attempt) throw new Error(`Thread is ${this.phase}; steering requires a running child.`);
219
- await attempt.steer(instruction);
220
- });
221
- }
222
-
223
- async retarget(objective: string): Promise<void> {
224
- return this.serialize(async () => {
225
- this.objective = objective;
226
- const attempt = this.attempt?.control;
227
- if (!attempt) return;
228
- await attempt.retarget(objective);
229
- });
230
- }
231
-
232
- async park(): Promise<void> {
233
- return this.serialize(async () => {
234
- this.parkRequested = true;
235
- const attempt = this.attempt?.control;
236
- if (attempt) await attempt.park();
237
- this.setPhase("parked");
238
- });
239
- }
240
-
241
203
  async stop(reason = "Subagent was aborted"): Promise<void> {
242
204
  return this.serialize(async () => {
243
205
  this.stopRequested = true;
@@ -369,6 +331,7 @@ export async function writeChildRetryPolicyExtension(
369
331
  modelRef?: string,
370
332
  ): Promise<ChildRetryPolicyExtension> {
371
333
  const dir = await mkdtemp(join(tmpdir(), "pi-subagents-policy-"));
334
+ writeTempOwnerMarker(dir);
372
335
  const filePath = join(dir, "no-provider-retries.mjs");
373
336
  const slash = modelRef?.indexOf("/") ?? -1;
374
337
  const selectedProvider = slash > 0 ? modelRef!.slice(0, slash) : undefined;
@@ -398,6 +361,7 @@ export async function writeChildRetryPolicyExtension(
398
361
 
399
362
  async function writePromptToTempFile(agentName: string, prompt: string): Promise<{ dir: string; filePath: string }> {
400
363
  const dir = await mkdtemp(join(tmpdir(), "pi-subagents-"));
364
+ writeTempOwnerMarker(dir);
401
365
  const safeName = agentName.replace(/[^\w.-]+/g, "_");
402
366
  const filePath = join(dir, `prompt-${safeName}.md`);
403
367
  try {
@@ -541,11 +505,6 @@ export async function runRpcAgentAttempt(options: RunRpcAttemptOptions): Promise
541
505
  let abortSettlement: Deferred<void> | undefined;
542
506
  let initialPromptResolved = false;
543
507
  const initialPrompt = deferred<{ accepted: boolean; error?: Error }>();
544
- let continuationCommandInFlight = false;
545
- let continuationAccepted = false;
546
- let continuationTurnStarted = false;
547
- let continuationTurnCompleted = false;
548
- let deferredAgentSettlement = false;
549
508
  const pendingRequests = new Map<string, PendingRequest>();
550
509
  const outcome = deferred<void>();
551
510
  const processClosed = deferred<void>();
@@ -562,13 +521,8 @@ export async function runRpcAgentAttempt(options: RunRpcAttemptOptions): Promise
562
521
 
563
522
  const setAttemptPhase = (phase: RpcControlPhase): void => {
564
523
  if (attemptToken !== undefined) control?.updateAttemptPhase(attemptToken, phase);
565
- switch (phase) {
566
- case "running":
567
- case "steering":
568
- case "interrupting":
569
- case "parked":
570
- emit({ kind: "status", status: phase });
571
- break;
524
+ if (phase === "running" || phase === "interrupting") {
525
+ emit({ kind: "status", status: phase });
572
526
  }
573
527
  };
574
528
 
@@ -685,142 +639,6 @@ export async function runRpcAgentAttempt(options: RunRpcAttemptOptions): Promise
685
639
  };
686
640
 
687
641
  const attemptControl: AttemptControl = {
688
- async steer(instruction: string): Promise<void> {
689
- if (finished) throw new Error("Thread already settled before it could be steered.");
690
- const acceptance = await initialPrompt.promise;
691
- if (!acceptance.accepted) throw acceptance.error ?? new Error("The initial prompt was rejected.");
692
- setAttemptPhase("steering");
693
- // Prompt+streamingBehavior performs the active→steer / idle→new-prompt
694
- // choice atomically inside Pi. Hold any old agent_settled event until this
695
- // command is accepted so an extension-handler race cannot drop the steer.
696
- continuationCommandInFlight = true;
697
- continuationAccepted = false;
698
- continuationTurnStarted = false;
699
- continuationTurnCompleted = false;
700
- deferredAgentSettlement = false;
701
- try {
702
- await send({ type: "prompt", message: asPlainTextRpcPrompt(instruction), streamingBehavior: "steer" });
703
- continuationAccepted = true;
704
- if (deferredAgentSettlement && !continuationTurnStarted) {
705
- // A handled input can succeed without starting a turn. Confirm the
706
- // server is idle before consuming the delayed settlement.
707
- const state = await send({ type: "get_state" }).catch(() => undefined);
708
- if ((state?.data as { isStreaming?: unknown } | undefined)?.isStreaming === false) {
709
- continuationAccepted = false;
710
- deferredAgentSettlement = false;
711
- settleRun();
712
- }
713
- }
714
- } catch (error) {
715
- continuationAccepted = false;
716
- if (deferredAgentSettlement) {
717
- deferredAgentSettlement = false;
718
- settleRun();
719
- }
720
- throw error;
721
- } finally {
722
- continuationCommandInFlight = false;
723
- }
724
- // Remain visibly steering until the next turn starts.
725
- },
726
- async retarget(objective: string): Promise<void> {
727
- if (finished) throw new Error("Thread already settled before it could be retargeted.");
728
- setAttemptPhase("interrupting");
729
- result.task = objective;
730
- const accepted = await abortAcceptedPrompt();
731
- if (!accepted) {
732
- if (!closed) await processClosed.promise;
733
- return;
734
- }
735
- if (finished || closed) throw new Error("Thread exited while retargeting.");
736
- // The aborted assistant message remains in the retained session/history,
737
- // but it must not classify the replacement objective as aborted.
738
- result.stopReason = undefined;
739
- result.errorMessage = undefined;
740
- result.exitCode = 0;
741
- // Tool failures belong to the abandoned objective. Keep them in session
742
- // history, but do not classify a successful replacement as failed.
743
- result.failedTools = undefined;
744
- try {
745
- await send({ type: "prompt", message: asPlainTextRpcPrompt(objective) });
746
- setAttemptPhase("running");
747
- } catch (error) {
748
- const promptError = error instanceof Error ? error : new Error(String(error));
749
- result.exitCode = 1;
750
- result.stopReason = "error";
751
- result.errorMessage = `Replacement prompt was rejected: ${promptError.message}`;
752
- if (promptError instanceof RpcCommandRejectedError) result.rpcPromptRejected = true;
753
- finish();
754
- terminate();
755
- if (!closed) await processClosed.promise;
756
- throw promptError;
757
- }
758
- },
759
- async park(): Promise<void> {
760
- const markParked = (): void => {
761
- result.parked = true;
762
- result.exitCode = 0;
763
- result.stopReason = undefined;
764
- result.errorMessage = undefined;
765
- result.rpcStartupFailed = undefined;
766
- result.rpcPromptRejected = undefined;
767
- };
768
- if (finished) {
769
- if (!closed) await processClosed.promise;
770
- if (result.parked) return;
771
- // Handshake/startup already tore the child down. Convert a pre-prompt
772
- // settlement into a park instead of throwing past the control tool.
773
- if (!result.rpcPromptAccepted) {
774
- markParked();
775
- return;
776
- }
777
- throw new Error("Thread already settled before it could be parked.");
778
- }
779
- setAttemptPhase("interrupting");
780
- if (!initialPromptResolved) {
781
- const parked = new Error("Run was parked before its initial prompt.");
782
- resolveInitialPrompt(false, parked);
783
- rejectPending(parked);
784
- markParked();
785
- setAttemptPhase("parked");
786
- finish();
787
- terminate();
788
- if (!closed) await processClosed.promise;
789
- return;
790
- }
791
- // Bound the abort settlement exactly like stop: a child that never
792
- // settles after abort must not hold the control operation forever.
793
- let parkTimer: ReturnType<typeof setTimeout> | undefined;
794
- let parkTimedOut = false;
795
- const parkDeadline = new Promise<boolean>((resolve) => {
796
- parkTimer = setTimeout(() => {
797
- parkTimedOut = true;
798
- resolve(false);
799
- }, RPC_ABORT_SETTLE_TIMEOUT_MS);
800
- if (typeof parkTimer.unref === "function") parkTimer.unref();
801
- });
802
- let accepted: boolean;
803
- try {
804
- accepted = await Promise.race([abortAcceptedPrompt(), parkDeadline]);
805
- } catch {
806
- /* a rejected abort still parks; termination below is the bounded fallback */
807
- accepted = false;
808
- } finally {
809
- if (parkTimer) clearTimeout(parkTimer);
810
- }
811
- if (abortSettlement) {
812
- const stable = abortSettlement;
813
- abortSettlement = undefined;
814
- stable.resolve();
815
- }
816
- if (!accepted && !parkTimedOut && !closed) await processClosed.promise;
817
- if (finished && accepted) throw new Error("Thread exited while parking.");
818
- markParked();
819
- setAttemptPhase("parked");
820
- finish();
821
- terminate();
822
- if (!closed) await processClosed.promise;
823
- },
824
642
  async stop(reason = "Subagent was aborted"): Promise<void> {
825
643
  if (finished) {
826
644
  if (!closed) await processClosed.promise;
@@ -862,6 +680,7 @@ export async function runRpcAgentAttempt(options: RunRpcAttemptOptions): Promise
862
680
  };
863
681
 
864
682
  if (attemptToken !== undefined) control?.attach(attemptToken, attemptControl);
683
+ if (proc.pid !== undefined) control?.noteChildPid(proc.pid);
865
684
  control?.markStarting();
866
685
 
867
686
  const processLine = (rawLine: string): void => {
@@ -929,15 +748,8 @@ export async function runRpcAgentAttempt(options: RunRpcAttemptOptions): Promise
929
748
  emit({ kind: "status", status: "running" });
930
749
  }
931
750
  if (event.type === "turn_start") {
932
- if (continuationCommandInFlight || continuationAccepted) {
933
- continuationTurnStarted = true;
934
- }
935
751
  setAttemptPhase("running");
936
752
  }
937
- if (event.type === "turn_end" && continuationTurnStarted) {
938
- continuationTurnCompleted = true;
939
- deferredAgentSettlement = false;
940
- }
941
753
 
942
754
  if (event.type === "message_update") {
943
755
  const type = event.assistantMessageEvent?.type;
@@ -998,18 +810,6 @@ export async function runRpcAgentAttempt(options: RunRpcAttemptOptions): Promise
998
810
  stable.resolve();
999
811
  return;
1000
812
  }
1001
- if ((continuationCommandInFlight || continuationAccepted) && !continuationTurnCompleted) {
1002
- // Pi may emit an old settlement while an extension handler is yielding
1003
- // and the atomic prompt command starts the continuation. Its successful
1004
- // response guarantees a new/queued turn, so defer this stale event until
1005
- // that continuation has completed a turn.
1006
- deferredAgentSettlement = true;
1007
- return;
1008
- }
1009
- continuationAccepted = false;
1010
- continuationTurnStarted = false;
1011
- continuationTurnCompleted = false;
1012
- deferredAgentSettlement = false;
1013
813
  settleRun();
1014
814
  }
1015
815
  };
@@ -1098,13 +898,7 @@ export async function runRpcAgentAttempt(options: RunRpcAttemptOptions): Promise
1098
898
  }
1099
899
 
1100
900
  try {
1101
- if (control?.isParkRequested()) {
1102
- resolveInitialPrompt(false, new Error("Run was parked before its initial prompt."));
1103
- result.parked = true;
1104
- result.exitCode = 0;
1105
- finish();
1106
- terminate();
1107
- } else if (control?.isStopRequested()) {
901
+ if (control?.isStopRequested()) {
1108
902
  resolveInitialPrompt(false, new Error("Run was stopped before its initial prompt."));
1109
903
  await attemptControl.stop();
1110
904
  } else {
@@ -1123,13 +917,13 @@ export async function runRpcAgentAttempt(options: RunRpcAttemptOptions): Promise
1123
917
  await send({ type: "get_state" }, readyTimeoutMs);
1124
918
  } catch (error) {
1125
919
  const handshakeError = error instanceof Error ? error : new Error(String(error));
1126
- if (!control?.isParkRequested() && !control?.isStopRequested()) {
920
+ if (!control?.isStopRequested()) {
1127
921
  failBeforePrompt(handshakeError, true);
1128
922
  } else {
1129
923
  resolveInitialPrompt(false, handshakeError);
1130
924
  }
1131
925
  }
1132
- if (!finished && !initialPromptResolved && !control?.isParkRequested() && !control?.isStopRequested()) {
926
+ if (!finished && !initialPromptResolved && !control?.isStopRequested()) {
1133
927
  // Pi starts the agent immediately after prompt preflight, before its
1134
928
  // success response necessarily reaches stdout. From this point on, a
1135
929
  // missing ACK is ambiguous and must never be recovered by replay.
package/src/runtime.ts CHANGED
@@ -20,28 +20,24 @@ import {
20
20
  type CompletionMessageItem,
21
21
  } from "./completion.ts";
22
22
  import { type ThinkingLevel } from "./config.ts";
23
+ import { threadRecordFromThread, upsertThreadRecord, type ThreadRecord } from "./durable.ts";
23
24
  import { isRunActiveStatus, monitor } from "./monitor.ts";
24
- import {
25
- persistRecoveryRecords,
26
- recoveryRecordFromFinalization,
27
- type RecoveryRecord,
28
- } from "./recovery.ts";
29
25
  import type { RpcRunControl } from "./rpc-run.ts";
30
- import type { SingleResult } from "./spawn.ts";
26
+ import type { StartBackgroundInternal } from "./thread-lifecycle.ts";
27
+ import { isFailedResult, type SingleResult } from "./spawn.ts";
31
28
  import type { IsolationMode, WorktreeFinalization, WorktreeIsolation } from "./worktree.ts";
32
29
 
33
30
  export type ThreadState =
34
31
  | "queued"
35
32
  | "resuming"
36
33
  | "running"
37
- | "steering"
38
34
  | "interrupting"
39
35
  | "parked"
40
36
  | "completed"
41
37
  | "failed"
42
38
  | "stopped";
43
39
 
44
- export type ThreadLifecycleOperation = "park" | "resume" | "fork" | "stop" | "settle";
40
+ export type ThreadLifecycleOperation = "park" | "resume" | "stop" | "settle";
45
41
 
46
42
  export interface SubagentThread {
47
43
  id: number;
@@ -54,6 +50,8 @@ export interface SubagentThread {
54
50
  executionCwd: string;
55
51
  thinkingLevel?: ThinkingLevel;
56
52
  isolation: IsolationMode;
53
+ /** Report-only reviewer dispatch: verdicts never chain into auto-fix. */
54
+ advisoryReview: boolean;
57
55
  worktree?: WorktreeIsolation;
58
56
  state: ThreadState;
59
57
  control: RpcRunControl;
@@ -74,15 +72,8 @@ export interface SubagentThread {
74
72
  /** A destructive stop retires context even if the active child settles later. */
75
73
  retireOnSettle?: boolean;
76
74
  retired?: boolean;
77
- /** Abort the active generation to a stable checkpoint and wait until its
78
- * queue work has published that checkpoint and released its slot. */
79
- park: () => Promise<"queued" | "active">;
80
75
  /** Installed by dispatch so the control tool can restart the same logical id. */
81
76
  resume: (objective?: string, ctx?: ExtensionContext) => Promise<SingleResult>;
82
- /** Create a new logical thread from this thread's retained Pi session branch. */
83
- fork: (objective?: string, ctx?: ExtensionContext) => Promise<SingleResult>;
84
- forkedFromRunId?: number;
85
- forkChildRunIds: number[];
86
77
  /** Dispatch-owned, generation-guarded worktree settlement hook. Its apply
87
78
  * runs under the canonical original-repository lane. */
88
79
  finalizeIsolation: (generation: number, result?: SingleResult) => Promise<WorktreeFinalization | undefined>;
@@ -98,6 +89,13 @@ export interface SubagentRuntime {
98
89
  getActiveTools: () => string[];
99
90
  /** False after session_shutdown; guards delivery and queue work. */
100
91
  sessionActive: boolean;
92
+ /** The process-wide background dispatcher. Set at tool registration so
93
+ * threads restored from the durable manifest can resume before any dispatch. */
94
+ dispatcher?: StartBackgroundInternal;
95
+ /** Run ids restored from the durable manifest at load; consumed by the
96
+ * one-time session-start notice. */
97
+ restoredRunIds: number[];
98
+ restoredNotified: boolean;
101
99
  /** Deliver a batch of completion messages to the main window, waking it only
102
100
  * when the batch needs a turn. */
103
101
  sendCompletionGroup: (items: CompletionMessageItem[]) => void;
@@ -111,7 +109,7 @@ export interface SubagentRuntime {
111
109
  registerRunResult: (runId: number, result: SingleResult) => void;
112
110
  /** Logical threads outlive process attempts and completed generations. */
113
111
  threads: Map<number, SubagentThread>;
114
- /** Resume/fork setup that has claimed a thread but has not yet enqueued its
112
+ /** Resume setup that has claimed a thread but has not yet enqueued its
115
113
  * next generation. Shutdown invalidates these claims and waits for cleanup. */
116
114
  preflightOperations: Set<Promise<void>>;
117
115
  /** Every session directory retained for this parent session, including
@@ -131,6 +129,8 @@ export function createRuntime(pi: ExtensionAPI, configPath: string): SubagentRun
131
129
  backgroundQueue,
132
130
  getActiveTools: () => pi.getActiveTools(),
133
131
  sessionActive: true,
132
+ restoredRunIds: [],
133
+ restoredNotified: false,
134
134
  sendCompletionGroup: (items) => {
135
135
  if (!runtime.sessionActive || items.length === 0) return;
136
136
  // A result arriving for one run does not mean sibling runs are done.
@@ -203,61 +203,86 @@ export function createRuntime(pi: ExtensionAPI, configPath: string): SubagentRun
203
203
  if (!runtime.sessionActive) return;
204
204
  runtime.sessionActive = false;
205
205
  const shutdownThreads = [...runtime.threads.values()];
206
+ const liveStates = new Set(["queued", "resuming", "running", "interrupting"]);
207
+ const previousStates = new Map(shutdownThreads.map((thread) => [thread.id, thread.state] as const));
206
208
  // Invalidate every lifecycle claim synchronously before the first await.
207
- // Resume/fork preflight checks both this version and sessionActive, then
209
+ // Resume preflight checks both this version and sessionActive, then
208
210
  // cleans any worktree/session it created before resolving its tracker.
211
+ // A generation already inside its settlement keeps its own claim: it
212
+ // finalizes its worktree and persists its terminal record itself.
213
+ const interrupting = shutdownThreads.filter((thread) =>
214
+ !thread.retired &&
215
+ thread.lifecycleOperation !== "settle" &&
216
+ liveStates.has(thread.state),
217
+ );
209
218
  for (const thread of shutdownThreads) {
210
219
  thread.lifecycleVersion++;
220
+ if (thread.retired) {
221
+ thread.lifecycleOperation = "stop";
222
+ continue;
223
+ }
224
+ if (thread.lifecycleOperation === "settle") continue;
211
225
  thread.lifecycleOperation = "stop";
212
- thread.retired = true;
213
- thread.retireOnSettle = true;
214
- thread.state = "stopped";
226
+ // Deliberately NOT retireOnSettle: shutdown interrupts to the last
227
+ // checkpoint but keeps the session/worktree resumable across reload.
228
+ thread.retireOnSettle = false;
229
+ if (liveStates.has(thread.state)) thread.state = "stopped";
215
230
  }
216
231
  const preflights = [...runtime.preflightOperations];
217
232
  runtime.completionBatcher.dispose();
218
233
  runtime.backgroundQueue.cancelAll();
219
234
  // Await live RPC process-tree cleanup and continuation preflight rollback
220
- // before removing sessions/worktrees or clearing ownership maps.
235
+ // before persisting records or releasing ownership maps.
221
236
  await Promise.all([
222
237
  Promise.all(
223
- shutdownThreads.map((thread) =>
238
+ interrupting.map((thread) =>
224
239
  thread.control.stop("Parent session shut down").catch(() => undefined),
225
240
  ),
226
241
  ),
227
242
  Promise.allSettled(preflights),
228
243
  runtime.backgroundQueue.waitForIdle(),
229
244
  ]);
230
- // Parked work owns no queue task, so shutdown is its final settlement.
231
- // Active/stopped tasks may already have finalized; the handle and callback
232
- // are idempotent and generation-guarded.
233
- const recoveryRecords: RecoveryRecord[] = [];
245
+ // Persist one record per non-retired thread, then keep exactly the
246
+ // artifacts those records reference. A thread whose settlement finished
247
+ // during the wait above already wrote its own terminal record; the
248
+ // lastResult-derived state below matches it.
249
+ const records: ThreadRecord[] = [];
234
250
  for (const thread of runtime.threads.values()) {
235
- const finalization = await thread.finalizeIsolation(thread.generation).catch(() => undefined);
236
- if (finalization?.status === "retained") {
237
- recoveryRecords.push(recoveryRecordFromFinalization(thread.id, finalization));
238
- if (!thread.isolationFailureNotified) {
239
- thread.isolationFailureNotified = true;
240
- try {
241
- thread.notifyIsolationFailure?.(finalization);
242
- } catch {
243
- /* the parent UI may already be shutting down */
244
- }
245
- }
251
+ if (thread.retired) continue;
252
+ const previous = previousStates.get(thread.id) ?? thread.state;
253
+ let state: "parked" | "completed" | "failed";
254
+ if (previous === "completed" || previous === "failed") {
255
+ state = previous;
256
+ } else if (thread.lifecycleOperation === "settle" && thread.lastResult) {
257
+ state = isFailedResult(thread.lastResult) ? "failed" : "completed";
258
+ } else {
259
+ state = "parked";
246
260
  }
261
+ records.push(threadRecordFromThread(thread, state));
247
262
  }
248
- // Persist before tearing down the old runtime. A /new, /resume, or quit
249
- // must not make the only recovery paths unreachable.
250
- await persistRecoveryRecords(runtime.configPath, recoveryRecords).catch(() => undefined);
251
- runtime.settledRuns.clear();
252
- runtime.settledListeners.clear();
253
- runtime.runControllers.clear();
263
+ await Promise.all(
264
+ records.map((record) => upsertThreadRecord(runtime.configPath, record).catch(() => undefined)),
265
+ );
266
+ // Retained-failure recovery records are persisted by the finalization
267
+ // itself; shutdown only drops sessions no record claims anymore.
268
+ const referenced = new Set(
269
+ records.flatMap((record) =>
270
+ [record.sessionDir, record.worktree?.tempDir].filter(Boolean) as string[],
271
+ ),
272
+ );
254
273
  for (const sessionDir of runtime.sessionDirs) {
274
+ if (referenced.has(sessionDir)) continue;
255
275
  try {
256
276
  rmSync(sessionDir, { recursive: true, force: true });
257
277
  } catch {
258
- /* best-effort */
278
+ /* best-effort; the state-root sweep catches leftovers later */
259
279
  }
260
280
  }
281
+ runtime.settledRuns.clear();
282
+ runtime.settledListeners.clear();
283
+ runtime.runControllers.clear();
284
+ // sessionDirs entries still referenced by records stay owned by the
285
+ // manifest; the next process re-registers them at restore.
261
286
  runtime.sessionDirs.clear();
262
287
  runtime.preflightOperations.clear();
263
288
  runtime.threads.clear();
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { SessionManager } from "@earendil-works/pi-coding-agent";
4
4
  import { existsSync } from "node:fs";
5
- import { mkdtemp, rm } from "node:fs/promises";
5
+ import { mkdir, mkdtemp, rm } from "node:fs/promises";
6
6
  import { tmpdir } from "node:os";
7
7
  import { join } from "node:path";
8
8
 
@@ -43,12 +43,17 @@ export async function forkRetainedSession(options: {
43
43
  targetCwd?: string;
44
44
  sessionDir: string;
45
45
  sessionId: string;
46
+ /** Parent directory for the cloned branch. Defaults to the OS temp dir;
47
+ * dispatch passes the durable state root. */
48
+ targetRoot?: string;
46
49
  }): Promise<ForkedSession> {
47
50
  const sourceSessionFile = await findRetainedSessionFile(
48
51
  options.sessionDir,
49
52
  options.sessionId,
50
53
  );
51
- const sessionDir = await mkdtemp(join(tmpdir(), "pi-subagent-session-fork-"));
54
+ const root = options.targetRoot ?? tmpdir();
55
+ await mkdir(root, { recursive: true });
56
+ const sessionDir = await mkdtemp(join(root, "pi-subagent-session-fork-"));
52
57
  try {
53
58
  // Supplying the new directory makes createBranchedSession write there.
54
59
  // cwdOverride rewrites the cloned header so a settled isolated session can