@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/README.md +82 -51
- package/agents/cleaner.md +13 -14
- package/agents/documenter.md +10 -17
- package/agents/explorer.md +6 -16
- package/agents/reviewer.md +28 -29
- package/agents/worker.md +14 -33
- package/package.json +1 -1
- package/src/announcements.ts +8 -0
- package/src/background.ts +21 -3
- package/src/dispatch.ts +721 -746
- package/src/durable.ts +336 -0
- package/src/fixloop.ts +30 -34
- package/src/format.ts +1 -8
- package/src/index.ts +7 -0
- package/src/monitor.ts +28 -29
- package/src/prompt.ts +4 -4
- package/src/rpc-run.ts +22 -228
- package/src/runtime.ts +69 -44
- package/src/session-fork.ts +7 -2
- package/src/spawn.ts +31 -28
- package/src/temp-hygiene.ts +194 -0
- package/src/thread-lifecycle.ts +1410 -1324
- package/src/tools.ts +21 -108
- package/src/widget.ts +3 -3
- package/src/worktree.ts +144 -4
package/src/prompt.ts
CHANGED
|
@@ -61,21 +61,21 @@ export function buildDelegationDirective(
|
|
|
61
61
|
: []),
|
|
62
62
|
...(hasCleaner
|
|
63
63
|
? [
|
|
64
|
-
`Use \`cleaner\` only
|
|
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
|
|
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
|
|
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/
|
|
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" | "
|
|
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
|
-
/**
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
179
|
-
|
|
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.
|
|
177
|
+
if (!this.stopRequested) this.setPhase("retrying");
|
|
190
178
|
}
|
|
191
179
|
|
|
192
180
|
markSettled(): void {
|
|
193
181
|
this.attempt = undefined;
|
|
194
|
-
if (!this.
|
|
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
|
-
|
|
566
|
-
|
|
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?.
|
|
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?.
|
|
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?.
|
|
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 {
|
|
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" | "
|
|
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
|
|
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
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
thread.
|
|
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
|
|
235
|
+
// before persisting records or releasing ownership maps.
|
|
221
236
|
await Promise.all([
|
|
222
237
|
Promise.all(
|
|
223
|
-
|
|
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
|
-
//
|
|
231
|
-
//
|
|
232
|
-
//
|
|
233
|
-
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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();
|
package/src/session-fork.ts
CHANGED
|
@@ -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
|
|
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
|