@ferris1225/pi-subagents 4.3.8 → 4.3.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.
@@ -28,6 +28,12 @@ import {
28
28
  } from "../presentation/format.ts";
29
29
  import { monitor } from "../presentation/monitor.ts";
30
30
  import { findDuplicateDispatch } from "../delegation/prompt.ts";
31
+ import {
32
+ findWriterLeaseScopeOverlap,
33
+ mergePhaseScopes,
34
+ normalizePhaseId,
35
+ normalizePhaseScope,
36
+ } from "../delegation/phase-scope.ts";
31
37
  import { persistRecoveryRecords, recoveryRecordFromFinalization } from "../isolation/recovery.ts";
32
38
  import type { SubagentRuntime, SubagentThread, ThreadState } from "./runtime.ts";
33
39
  import { forkRetainedSession } from "../execution/session-fork.ts";
@@ -105,6 +111,9 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
105
111
  startOptions: StartBackgroundOptions = {},
106
112
  ): Promise<SingleResult> => {
107
113
  const {
114
+ phaseId: requestedPhaseId,
115
+ scope: requestedScope,
116
+ writeCapable: requestedWriteCapable,
108
117
  existingThread,
109
118
  appendedObjectiveOnResume = false,
110
119
  environment,
@@ -127,16 +136,30 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
127
136
  const resolveLiveAgentTools = (candidate: AgentConfig): AgentConfig =>
128
137
  resolveAgentTools({ ...candidate, tools: discoveredAgent.tools }, runtime.getActiveTools());
129
138
  const agent = resolveLiveAgentTools(discoveredAgent);
139
+
140
+ const originalCwd = resolve(cwd ?? runCtx.cwd);
141
+ let phaseId: string | undefined;
142
+ let scope: ReturnType<typeof normalizePhaseScope>;
143
+ try {
144
+ phaseId = normalizePhaseId(existingThread ? existingThread.phaseId : requestedPhaseId);
145
+ const requested = normalizePhaseScope(requestedScope, originalCwd);
146
+ scope = existingThread ? mergePhaseScopes(existingThread.scope, requested) : requested;
147
+ } catch (error) {
148
+ return failedStartResult(agentName, task, error instanceof Error ? error.message : String(error));
149
+ }
150
+ const currentWriteCapable = isWriteCapableAgent(agent);
151
+ const priorWriteCapable = existingThread
152
+ ? (existingThread.writeCapable ?? existingThread.agentName !== "scout")
153
+ : Boolean(requestedWriteCapable);
154
+ const writeCapable = priorWriteCapable || currentWriteCapable;
130
155
  if (isolation === "worktree" && !isWorktreeCapableAgent(agent)) {
131
156
  return {
132
157
  ...failedStartResult(agentName, task, `Agent "${agentName}" is read-only; worktree isolation is available only to write-capable agents such as artisan.`),
133
158
  isolation,
134
159
  };
135
160
  }
136
-
137
- const originalCwd = resolve(cwd ?? runCtx.cwd);
138
161
  if (!existingThread) {
139
- const duplicate = findDuplicateDispatch(runtime.threads.values(), task, originalCwd);
162
+ const duplicate = findDuplicateDispatch(runtime.threads.values(), task, originalCwd, phaseId);
140
163
  if (duplicate?.kind === "active") {
141
164
  return failedStartResult(
142
165
  agentName,
@@ -150,7 +173,17 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
150
173
  return failedStartResult(
151
174
  agentName,
152
175
  task,
153
- `Run #${duplicate.source.id} (${duplicate.source.agentName}) already ${duplicate.source.state} this exact brief and kept its context; its result was delivered. Resume #${duplicate.source.id} with an appended objective instead of paying for a second run, or restate the brief with what changed.`,
176
+ `Run #${duplicate.source.id} (${duplicate.source.agentName}) already ${duplicate.source.state} this logical phase and kept its context; its result was delivered. Resume #${duplicate.source.id} with an appended objective instead of paying for a second run${phaseId ? "; keep using the same phaseId on that thread" : ", or restate the brief with what changed"}.`,
177
+ );
178
+ }
179
+ }
180
+ if (writeCapable && scope) {
181
+ const conflict = findWriterLeaseScopeOverlap(scope, runtime.threads.values(), existingThread?.id);
182
+ if (conflict) {
183
+ return failedStartResult(
184
+ agentName,
185
+ task,
186
+ `Declared writer scope ${conflict.overlap.left} overlaps active run #${conflict.lease.id} scope ${conflict.overlap.right}; no new generation was started.`,
154
187
  );
155
188
  }
156
189
  }
@@ -230,6 +263,9 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
230
263
  thread.generation = generation;
231
264
  thread.agentName = agent.name;
232
265
  thread.task = task;
266
+ thread.phaseId = phaseId;
267
+ thread.scope = scope;
268
+ thread.writeCapable = writeCapable;
233
269
  thread.cwd = originalCwd;
234
270
  thread.executionCwd = executionCwd;
235
271
  thread.thinkingLevel = thinkingLevel;
@@ -253,6 +289,9 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
253
289
  generation,
254
290
  agentName: agent.name,
255
291
  task,
292
+ phaseId,
293
+ scope,
294
+ writeCapable,
256
295
  cwd: originalCwd,
257
296
  executionCwd,
258
297
  thinkingLevel,
@@ -281,7 +320,7 @@ export function createBackgroundDispatcher(options: BackgroundDispatcherOptions)
281
320
  // Shared write-capable runs serialize on the repository lane so their
282
321
  // edits cannot race; the lane wait releases the process slot because it
283
322
  // is write serialization, not pool pacing.
284
- const reserveManagedLane = isolation === "shared" && isWriteCapableAgent(agent);
323
+ const reserveManagedLane = isolation === "shared" && writeCapable;
285
324
  const runGeneration = async (backgroundSignal: AbortSignal, controller: AbortController): Promise<void> => {
286
325
  if (runtime.threads.get(runId)?.generation !== generation) return;
287
326
  if (isolation === "worktree" && !worktree) {
@@ -718,7 +757,11 @@ export function installThreadLifecycle(thread: SubagentThread, deps: ThreadLifec
718
757
  });
719
758
  };
720
759
 
721
- thread.resume = async (objective?: string, resumeCtx?: ExtensionContext): Promise<SingleResult> => {
760
+ thread.resume = async (
761
+ objective?: string,
762
+ resumeCtx?: ExtensionContext,
763
+ metadata?: { scope?: Parameters<typeof normalizePhaseScope>[0] },
764
+ ): Promise<SingleResult> => {
722
765
  const requestedObjective = objective?.trim();
723
766
  if (!runtime.sessionActive || runtime.threads.get(runId) !== thread) {
724
767
  return failedStartResult(thread.agentName, thread.task, `Run #${runId} belongs to a parent session that has shut down.`);
@@ -726,6 +769,14 @@ export function installThreadLifecycle(thread: SubagentThread, deps: ThreadLifec
726
769
  if (objective !== undefined && !requestedObjective) {
727
770
  return failedStartResult(thread.agentName, thread.task, "resume objective must be non-blank when provided.");
728
771
  }
772
+ const continuationPhaseId = thread.phaseId;
773
+ let continuationScope: ReturnType<typeof normalizePhaseScope>;
774
+ try {
775
+ const additionalScope = normalizePhaseScope(metadata?.scope, thread.cwd);
776
+ continuationScope = mergePhaseScopes(thread.scope, additionalScope);
777
+ } catch (error) {
778
+ return failedStartResult(thread.agentName, thread.task, error instanceof Error ? error.message : String(error));
779
+ }
729
780
  if (thread.retired) return failedStartResult(thread.agentName, thread.task, `Run #${runId} was retired by subagent_stop.`);
730
781
  if (thread.resumeUnavailableReason) {
731
782
  return failedStartResult(thread.agentName, thread.task, thread.resumeUnavailableReason);
@@ -750,6 +801,7 @@ export function installThreadLifecycle(thread: SubagentThread, deps: ThreadLifec
750
801
  sessionId: previousSessionId,
751
802
  sessionDir: previousSessionDir,
752
803
  };
804
+ thread.admissionScope = continuationScope;
753
805
  thread.lifecycleOperation = "resume";
754
806
  thread.state = "resuming";
755
807
  const finishPreflight = beginRuntimePreflight(runtime);
@@ -763,6 +815,7 @@ export function installThreadLifecycle(thread: SubagentThread, deps: ThreadLifec
763
815
  // Never wait forever on a previous generation that is still settling
764
816
  // (e.g. blocked behind the managed repository lane in finalization).
765
817
  if (!(await quiesced(thread.generationCompletion))) {
818
+ if (ownsResumeReservation(runtime, thread, reservation)) thread.state = previousState;
766
819
  return failedStartResult(
767
820
  thread.agentName,
768
821
  thread.task,
@@ -829,6 +882,8 @@ export function installThreadLifecycle(thread: SubagentThread, deps: ThreadLifec
829
882
  thread.isolation,
830
883
  {
831
884
  existingThread: thread,
885
+ phaseId: continuationPhaseId,
886
+ scope: continuationScope,
832
887
  appendedObjectiveOnResume: objective !== undefined,
833
888
  environment: {
834
889
  ctx: currentCtx,
@@ -883,6 +938,7 @@ export function installThreadLifecycle(thread: SubagentThread, deps: ThreadLifec
883
938
  );
884
939
  } finally {
885
940
  finishPreflight();
941
+ if (thread.lifecycleVersion === reservation.version) thread.admissionScope = undefined;
886
942
  if (
887
943
  thread.lifecycleOperation === "resume" &&
888
944
  thread.lifecycleVersion === reservation.version
@@ -58,6 +58,9 @@ function createRestoredThread(
58
58
  generation: record.generation,
59
59
  agentName: record.agentName,
60
60
  task: record.task,
61
+ phaseId: record.phaseId,
62
+ scope: record.scope,
63
+ writeCapable: record.writeCapable ?? record.agentName !== "scout",
61
64
  cwd: record.cwd,
62
65
  executionCwd: record.executionCwd,
63
66
  ...(record.thinkingLevel ? { thinkingLevel: record.thinkingLevel as ThinkingLevel } : {}),
@@ -4,6 +4,7 @@ import { realpath } from "node:fs/promises";
4
4
  import { join, resolve } from "node:path";
5
5
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
6
6
  import { isWriteCapableAgent, type AgentConfig } from "../delegation/agents.ts";
7
+ import type { PhaseScope } from "../delegation/phase-scope.ts";
7
8
  import { roleThinkingLevel, type SubagentsConfig, type ThinkingLevel } from "../configuration/config.ts";
8
9
  import { removeThreadRecord, threadRecordFromThread, upsertThreadRecord } from "./durable.ts";
9
10
  import {
@@ -195,9 +196,14 @@ export interface ResumeReservation {
195
196
  sessionDir?: string;
196
197
  }
197
198
 
198
- /** The dispatcher's full internal entry point; the public tool surface only
199
- * uses the first four parameters. */
199
+ /** Dispatcher's internal entry point. Public phase/scope claims are normalized
200
+ * into options; resume adds lifecycle-only continuation fields there too. */
200
201
  export interface StartBackgroundOptions {
202
+ /** Normalized identity and claims for a fresh or resumed generation. */
203
+ phaseId?: string;
204
+ scope?: PhaseScope;
205
+ /** Fresh-dispatch hint OR-merged with live capability; false cannot downgrade a writer. Resume stays monotonic. */
206
+ writeCapable?: boolean;
201
207
  /** Resume path only: the thread whose retained context continues. */
202
208
  existingThread?: SubagentThread;
203
209
  appendedObjectiveOnResume?: boolean;
@@ -34,6 +34,13 @@ function renderFirstLine(result: { content?: unknown }, label: string, theme: an
34
34
  }
35
35
 
36
36
  export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime): void {
37
+ const ResumeScopeSchema = Type.Optional(Type.Object({
38
+ paths: Type.Optional(Type.Array(Type.String({ minLength: 1, pattern: "\\S" }))),
39
+ symbols: Type.Optional(Type.Array(Type.Object({
40
+ path: Type.String({ minLength: 1, pattern: "\\S" }),
41
+ name: Type.String({ minLength: 1, pattern: "\\S" }),
42
+ }))),
43
+ }, { description: "Additional declarative write claims for resume; normalized claims are unioned with retained scope and cannot remove it. Scope is conflict metadata, not permissions or a sandbox." }));
37
44
  const SubagentControlParams = Type.Object({
38
45
  action: StringEnum(["steer", "resume", "park"] as const, {
39
46
  description:
@@ -43,6 +50,7 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
43
50
  objective: Type.Optional(
44
51
  Type.String({ description: "Guidance for steer (required and nonblank), or an optional appended objective for resume. Ignored by park." }),
45
52
  ),
53
+ scope: ResumeScopeSchema,
46
54
  });
47
55
 
48
56
  /** A thread that a steer can continue instead of reject: it is not live, but
@@ -52,7 +60,7 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
52
60
  pi.registerTool({
53
61
  name: "subagent_control",
54
62
  label: "Subagent Control",
55
- description: "Steer a running child with additional guidance (continuing it if it has settled or is parked), resume a parked/settled thread, or park a running thread at a stable checkpoint, by stable run id.",
63
+ description: "Steer a running child, resume a parked/settled thread, or park a running thread by stable run id. Resume keeps phaseId immutable and may only extend retained scope.",
56
64
  parameters: SubagentControlParams,
57
65
 
58
66
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
@@ -103,7 +111,9 @@ export function registerLookupTools(pi: ExtensionAPI, runtime: SubagentRuntime):
103
111
  return textResult("resume objective must be non-blank when provided.");
104
112
  }
105
113
  const hadRetainedSession = Boolean(thread.sessionId && thread.sessionDir);
106
- const pending = await thread.resume(requestedObjective, ctx);
114
+ const pending = await thread.resume(requestedObjective, ctx, {
115
+ scope: params.scope,
116
+ });
107
117
  if (pending.exitCode !== -1) return textResult(getResultOutput(pending));
108
118
  const currentObjective = formatTaskSummary(requestedObjective ?? thread.task, 80, false);
109
119
  const mode = requestedObjective