@henryqw/pi-subagent 22.1.0 → 22.2.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/README.md CHANGED
@@ -21,7 +21,9 @@ pi install npm:pi-mcp-adapter
21
21
 
22
22
  ## Use
23
23
 
24
- ![Delegation mode routing and the checked changeset lifecycle through recovery, validation, and promotion](./docs/delegation-routing.svg)
24
+ ![Main, its coordinator extension, and a Herdr subagent through checked work, worker failure, and coordinator interruption](./docs/worker-lifecycle.svg)
25
+
26
+ The coordinator is extension code running in Main's Pi process, not another agent. It persists the request, launches the Herdr subagent, checks its committed work, and reports saved results; Main chooses what to stage and promote. If a worker fails, the coordinator reports attention after the wave settles, not necessarily immediately while siblings work. If Main's Pi process stops, no follow-up is guaranteed: restart and run `/subagent recover` in the canonical repository to classify orphaned interruptions and send Main one recovery report. Main then uses `subagent_status` for exact evidence and deliberate next actions. A stale `running` state does not prove a worker is still active; interrupted or ambiguous actions are never replayed automatically.
25
27
 
26
28
  Ask Main to delegate a bounded task, such as “Have a scout trace sign-in without editing files.” Main uses `delegate_task`; you receive progress and a result or an actionable failure while Main remains available.
27
29
 
@@ -31,7 +33,8 @@ In the TUI, the compact widget uses one row per visible agent/task: `D` or `I` m
31
33
 
32
34
  | Surface | Type | Purpose |
33
35
  | --- | --- | --- |
34
- | `/subagent` | command | Pick direct or isolated work to inspect; send or edit pending instructions for a locally active isolated changeset. Requires interactive TUI or RPC. |
36
+ | `/subagent` | command | Browse work, launch isolated status inspection in a separate Herdr tab with the `fast` model, or send/edit pending instructions. Requires interactive TUI or RPC; inspection also requires a Herdr-managed pane and a read-only `scout` Role. |
37
+ | `/subagent recover` | command | Classify orphaned isolated work in the current canonical repository and send Main one follow-up with request IDs, blockers, and reported next actions. No UI or Herdr pane required. |
35
38
  | `delegate_task` | tool | Start read-only direct work or a durable isolated checked graph. |
36
39
  | `subagent_status` | tool | Read durable isolated state and the exact allowed continuation without replaying work. |
37
40
  | `subagent_resume` | tool | Perform the reported `retry`, `verify`, or `finalize` continuation; does not replace staging or integration. |
@@ -123,21 +126,23 @@ An isolated request has one durable ID, one goal, and 1–8 typed tasks:
123
126
 
124
127
  `dependsOn` controls scheduling. `contextFrom` may name completed text tasks and preserves the declared order. Changesets require focused task checks; a graph with changesets requires final checks. A canonical root `package.json` test script is required: the runner adds `pnpm test` to final checks if missing, and Main runs it on the combined integration checkout before promotion. Checks and judgments bind to exact Git identities; candidate drift, Main drift, mutation during validation, ambiguity, or conflicts stop promotion and retain evidence.
125
128
 
126
- `delegate_task` and `subagent_resume` return a durable request ID after its state is saved; productive work continues while Main is free. Pi delivers completion or attention as a follow-up message in the launching session. Use `subagent_status` if delivery is missed or you need current state.
129
+ `delegate_task`, `subagent_resume`, and `subagent_integrate advance` return a durable request ID after the wave is saved; productive work continues while Main is free. Pi sends a follow-up in the launching session whenever a checked worker becomes ready, and again when the wave finishes or needs attention. Use `subagent_status` if delivery is missed or you need current state.
127
130
 
128
- The worker produces a clean committed candidate, then runs focused preliminary checks and optional judgment. The runner records a ready candidate but **does not integrate it automatically**. Main inspects `subagent_status`, selects each exact candidate with `subagent_stage` in an owned integration worktree, and resolves conflicts there. `subagent_integrate` can advance dependent tasks from a staged snapshot, validate the combined tip with full root checks and optional final judgment, and promote it to Main only after exact validation. A definitive failed combined check/review allows one committed correction in the integration checkout followed by another validation. Main must explicitly promote; promotion and cleanup never silently discard retained work.
131
+ The worker produces a committed candidate without tracked or untracked changes, then runs focused preliminary checks and optional judgment. Ignored generated files do not block candidacy, but prevent automatic worktree cleanup; inspect them before deleting retained work. The runner records each ready candidate immediately but **does not integrate it automatically**. Main can inspect `subagent_status` and use `subagent_stage` to select the exact candidate in an owned integration worktree while siblings are still working; conflicts are resolved there. Stage and resolve are available during an active wave, but rejection and revision wait for the wave to settle. `subagent_integrate` can advance dependent tasks from a staged snapshot, validate the combined tip with full root checks and optional final judgment after the wave settles, and promote it to Main only after exact validation. A definitive failed combined check/review allows one committed correction in the integration checkout followed by another validation. Main must explicitly promote; promotion and cleanup never silently discard retained work.
129
132
 
130
- Open `/subagent` without arguments to pick work by label. The menu shows direct records on this session branch and isolated requests in the current canonical Git checkout (including requests launched in other sessions). Pick a task to inspect status and recovery in separate notices; retained paths and worker identities remain exact even when the request has many attempts or generations. Refresh or choose completed/history for older work. Invalid state IDs are warned about and preserved. Outside Git or when isolated inventory is unavailable, direct recovery still works. Browsing does not resume work or change resources.
133
+ Open `/subagent` without arguments to pick work by label. The menu shows direct records on this session branch and isolated requests in the current canonical Git checkout (including requests launched in other sessions). Pick an isolated request and **Inspect status and recovery** to launch a non-focused Herdr tab with a read-only `scout` Role on the configured `fast` route. Its prompt contains bounded saved status (including retained paths, but not raw launch environment or session transcripts); the analysis stays in that tab and does not queue a Main turn. The tab and session file are recorded for recovery even if launch becomes uncertain. The read-only `subagent_status` tool remains Main's authority for exact identities before any stage or recovery action. If Herdr or the read-only fast route is unavailable, inspection fails without changing the request; Main can use `subagent_status`. Refresh or choose completed/history for older work. Invalid state IDs are warned about and preserved. Outside Git or when isolated inventory is unavailable, direct recovery still works. Browsing does not resume work or change resources.
131
134
 
132
- For a locally active unsealed isolated changeset, choose **Send follow-up instructions** to open a native multiline editor; submission queues the instruction, not a completed revision. Choose **Edit queued instructions** to withdraw *all still-pending* instructions for that selected task before the editor opens. Their full text appears in FIFO order separated by blank lines. Explicit submission queues **one replacement** at the tail. Cancel leaves the withdrawn instructions removed; already-claimed instructions, initial prompts, and automatic corrections cannot be recalled. A worker may seal during editing: failed submission reopens the full submitted text for deliberate editing or copying, never automatically requeues it. Combined text above the single-instruction limit is preserved in the editor but rejected until shortened. Cancelling before withdrawal makes no change. Neither action touches Main's editor or Pi's global Option+Up queue shortcut. No-UI sessions must use the agent tools for inspection and recovery.
135
+ For a locally active unsealed isolated changeset, choose **Send follow-up instructions** to open a native multiline editor; submission queues the instruction, not a completed revision. Choose **Edit queued instructions** to withdraw *all still-pending* instructions for that selected task before the editor opens. Their full text appears in FIFO order separated by blank lines. Explicit submission queues **one replacement** at the tail. Cancel leaves the withdrawn instructions removed; already-claimed instructions, initial prompts, and automatic corrections cannot be recalled. A worker may seal during editing: failed submission reopens the full submitted text for deliberate editing or copying, never automatically requeues it. Combined text above the single-instruction limit is preserved in the editor but rejected until shortened. Cancelling before withdrawal makes no change. Neither action touches Main's editor or Pi's global Option+Up queue shortcut. No-UI sessions can run `/subagent recover` or use agent tools for individual inspection and recovery.
133
136
 
134
137
  A queued follow-up runs after the current turn settles and must produce a new clean commit that passes preliminary checks again. When no queued revision remains, the checked candidate seals immediately; there is no guaranteed post-completion editing window. Late follow-ups fail visibly.
135
138
 
136
139
  ### Isolated recovery
137
140
 
138
- Main uses the recovery tools in the interface table above. The following details describe their exact identity and retained-work requirements.
141
+ After a coordinator interruption, run `/subagent recover` once. It acquires the repository productive lease before changing any orphaned `pending` or `running` state, preserves unreadable state files and retained candidates, and reports blockers and existing continuations to Main without replaying prompts, merges, promotion, or cleanup. If another productive run holds the lease, it only reads status and reports the live owner; wait rather than taking over. Repeating the command is safe. Main uses `subagent_status` for exact identities and the recovery tools in the interface table above for deliberate decisions; the command never stages or promotes. Direct Herdr tab recovery remains in `/subagent`.
142
+
143
+ The following details describe exact identity and retained-work requirements.
139
144
 
140
- Pass the exact `generation`, candidate (`taskId`, `attempt`, `candidate`) and `expectedTip` identities reported by status to stage; pass the generation and combined `expectedTip` to integrate. If Main advances cleanly before promotion, call `subagent_integrate refresh` with the recorded `expectedMain`, old `expectedTip`, and new clean `newMain`. Main must restage selected immutable candidates into the new generation and rerun combined checks. Dirty or divergent Main blocks refresh. Stale identities fail. At most two integration worktrees may remain unreleased per request; a superseded generation is read-only. To release a superseded *clean* integration checkout, call `subagent_integrate` with `{ id, action: "release", generation, expectedTip }`, using that generation's last staged tip (or integration base when no stage completed). To release a rejected candidate after its worker has terminated and all generations using it are released, pass `{ id, action: "release", generation: <latest generation number, or 1>, taskId, attempt, expectedTip: <candidate tip> }`. Release persists each host/checkout/branch step, verifies ownership and cleanliness (including ignored files), and deletes the branch only if its ref still names the exact tip. Dirty, conflicted, changed, or uncertain resources remain for manual inspection; retry the same release after resolving the obstacle. Released checkouts free a retained slot; abort is available after every retained resource is released. Rejection can invalidate dependent work; if fresh dependent execution is impossible, start a new request instead of forcing promotion. Productive requests have no whole-run wall-clock deadline. Resume does not reset the recorded policy or correction count. Child limits, abort signals, process I/O, status inspection, exact termination, and cleanup retain finite safety bounds.
145
+ Pass the exact `generation`, candidate (`taskId`, `attempt`, `candidate`) and `expectedTip` identities reported by status to stage; pass the generation and combined `expectedTip` to integrate. If Main advances cleanly before promotion, call `subagent_integrate refresh` with the recorded `expectedMain`, old `expectedTip`, and new clean `newMain`. Main must restage selected immutable candidates into the new generation and rerun combined checks. Dirty or divergent Main blocks refresh. Stale identities fail. If integration allocation was uncertain, `subagent_stage resolve` first proves the owned checkout is still at its base before starting the selected merge; otherwise it retains the intent. At most two integration worktrees may remain unreleased per request; a superseded generation is read-only. To release a superseded *clean* integration checkout, call `subagent_integrate` with `{ id, action: "release", generation, expectedTip }`, using that generation's last staged tip (or integration base when no stage completed). To release a rejected candidate after its worker has terminated and all generations using it are released, pass `{ id, action: "release", generation: <latest generation number, or 1>, taskId, attempt, expectedTip: <candidate tip> }`. Release persists each host/checkout/branch step, verifies ownership and cleanliness (including ignored files), and deletes the branch only if its ref still names the exact tip. Dirty, conflicted, changed, or uncertain resources remain for manual inspection; retry the same release after resolving the obstacle. Released checkouts free a retained slot; abort is available after every retained resource is released. Rejection can invalidate dependent work; if fresh dependent execution is impossible, start a new request instead of forcing promotion. Productive requests have no whole-run wall-clock deadline. Resume does not reset the recorded policy or correction count. Child limits, abort signals, process I/O, status inspection, exact termination, and cleanup retain finite safety bounds.
141
146
 
142
147
  The extension never pushes, opens a pull request, publishes, deploys, force-cleans recoverable work, or silently falls back to Main.
143
148
 
@@ -339,24 +339,27 @@ export class CheckedGitRuntime {
339
339
  return worktree;
340
340
  }
341
341
  async inspectInFlightWorkspace(cwd, context) {
342
- const branch = oneLine(await this.requireGit(["symbolic-ref", "--quiet", "HEAD"], cwd, context), "branch reference");
343
- const head = oid(await this.requireGit(["rev-parse", "--verify", "HEAD^{commit}"], cwd, context), "HEAD");
344
- const hadGitlinks = await this.hasGitlinks(cwd, context);
345
- const inspection = await inspectWorktreeDirty(cwd, this.gitRunner(context));
346
- if (inspection.failure)
347
- throw new Error(`Worktree inspection failed: ${inspection.failure}`);
348
- const index = oid(await this.requireGit(["write-tree"], cwd, context), "index tree");
349
- const tree = oid(await this.requireGit(["rev-parse", "--verify", "HEAD^{tree}"], cwd, context), "HEAD tree");
350
- const hasGitlinks = hadGitlinks || await this.hasGitlinks(cwd, context);
351
- const finalBranch = oneLine(await this.requireGit(["symbolic-ref", "--quiet", "HEAD"], cwd, context), "branch reference");
352
- const finalHead = oid(await this.requireGit(["rev-parse", "--verify", "HEAD^{commit}"], cwd, context), "HEAD");
353
- if (branch !== finalBranch || head !== finalHead)
354
- throw new Error("Git workspace changed during in-flight identity inspection.");
355
- return {
356
- candidate: { branch, head, index, tree },
357
- clean: !inspection.dirty,
358
- supported: !hasGitlinks,
359
- };
342
+ for (let attempt = 0; attempt < 2; attempt++) {
343
+ const branch = oneLine(await this.requireGit(["symbolic-ref", "--quiet", "HEAD"], cwd, context), "branch reference");
344
+ const head = oid(await this.requireGit(["rev-parse", "--verify", "HEAD^{commit}"], cwd, context), "HEAD");
345
+ const hadGitlinks = await this.hasGitlinks(cwd, context);
346
+ const inspection = await inspectWorktreeDirty(cwd, this.gitRunner(context), false);
347
+ if (inspection.failure)
348
+ throw new Error(`Worktree inspection failed: ${inspection.failure}`);
349
+ const index = oid(await this.requireGit(["write-tree"], cwd, context), "index tree");
350
+ const tree = oid(await this.requireGit(["rev-parse", "--verify", "HEAD^{tree}"], cwd, context), "HEAD tree");
351
+ const hasGitlinks = hadGitlinks || await this.hasGitlinks(cwd, context);
352
+ const finalBranch = oneLine(await this.requireGit(["symbolic-ref", "--quiet", "HEAD"], cwd, context), "branch reference");
353
+ const finalHead = oid(await this.requireGit(["rev-parse", "--verify", "HEAD^{commit}"], cwd, context), "HEAD");
354
+ if (branch !== finalBranch || head !== finalHead)
355
+ continue;
356
+ return {
357
+ candidate: { branch, head, index, tree },
358
+ clean: !inspection.dirty,
359
+ supported: !hasGitlinks,
360
+ };
361
+ }
362
+ throw new Error("Git workspace changed during in-flight identity inspection.");
360
363
  }
361
364
  async inspectWorkspace(cwd, strictIgnored, context) {
362
365
  const branch = oneLine(await this.requireGit(["symbolic-ref", "--quiet", "HEAD"], cwd, context), "branch reference");
@@ -1,5 +1,6 @@
1
1
  import { type ExecuteRequest, type HostAllocationIntent, type HostAllocationPlan, type TaskAttempt, type TaskRequest, type WorkspaceIdentity } from "./schema.ts";
2
2
  import { type AllocationReconciliation, type HostAllocationKind, type HostAllocationResult, type HostCleanupKind, type HostRuntime, type InFlightTaskCandidateInspector, type OperationContext, type TextTaskContext, type TransientLaunchHandle, type VerifiedLaunch, type WorkerResult } from "./runner.ts";
3
+ import { type EphemeralSubagentExecutionBudget } from "./ephemeral.ts";
3
4
  export interface HostProcessOptions {
4
5
  cwd: string;
5
6
  signal: AbortSignal;
@@ -21,6 +22,7 @@ export interface HerdrHostRuntimeOptions {
21
22
  env?: NodeJS.ProcessEnv;
22
23
  leaseDirectory?: string;
23
24
  lsofCommand?: string;
25
+ executionBudget?: () => Omit<EphemeralSubagentExecutionBudget, "startedAt">;
24
26
  }
25
27
  export declare function workspaceLabel(attempt: Pick<TaskAttempt, "correlationToken">): string;
26
28
  export declare class HerdrHostRuntime implements HostRuntime {
@@ -34,6 +36,7 @@ export declare class HerdrHostRuntime implements HostRuntime {
34
36
  private readonly env;
35
37
  private readonly leaseDirectory;
36
38
  private readonly lsofCommand;
39
+ private readonly executionBudget?;
37
40
  constructor(options: HerdrHostRuntimeOptions);
38
41
  /** Fail-closed Herdr caller/capability gate. This method creates no state or host resource. */
39
42
  preflightHost(input: {
@@ -8,6 +8,7 @@ import { createHerdrClient, hasHerdrErrorCode, herdrCommandFailure, startPiAgent
8
8
  import { isCleanCommitted, } from "./schema.js";
9
9
  import { buildChangesetTaskPrompt, withTransientLaunch, } from "./runner.js";
10
10
  import { runProcess as defaultRunProcess } from "./process.js";
11
+ import { EXECUTION_BUDGET_ENV } from "./ephemeral.js";
11
12
  const MIN_HERDR_VERSION = [0, 9, 0];
12
13
  const MIN_HERDR_PROTOCOL = 22;
13
14
  const HERDR_OPERATION_CAP_MS = 30_000;
@@ -252,6 +253,7 @@ export class HerdrHostRuntime {
252
253
  env;
253
254
  leaseDirectory;
254
255
  lsofCommand;
256
+ executionBudget;
255
257
  constructor(options) {
256
258
  this.inspectInFlightCandidate = options.inspectInFlightTaskCandidate;
257
259
  this.execute = options.runProcess ?? defaultRunProcess;
@@ -263,6 +265,7 @@ export class HerdrHostRuntime {
263
265
  this.env = options.env ?? process.env;
264
266
  this.leaseDirectory = resolve(options.leaseDirectory ?? join(extensionConfigDir("pi-subagent"), "leases"));
265
267
  this.lsofCommand = options.lsofCommand ?? "lsof";
268
+ this.executionBudget = options.executionBudget;
266
269
  }
267
270
  /** Fail-closed Herdr caller/capability gate. This method creates no state or host resource. */
268
271
  async preflightHost(input, context) {
@@ -741,9 +744,12 @@ export class HerdrHostRuntime {
741
744
  assertWorkerTabIntent(allocation, task, attempt);
742
745
  await this.createPrivateLease(allocation.leasePath, allocation.token);
743
746
  await this.waitForStableWorkspacePanes(allocation.workspaceId, allocation.worktreeCwd, context);
747
+ const budget = this.executionBudget?.();
744
748
  const args = [
745
749
  "tab", "create", "--workspace", allocation.workspaceId, "--cwd", allocation.worktreeCwd,
746
- "--label", allocation.label, "--env", `${PROCESS_LEASE_ENV}=${allocation.leasePath}`, "--no-focus",
750
+ "--label", allocation.label, "--env", `${PROCESS_LEASE_ENV}=${allocation.leasePath}`,
751
+ ...(budget ? ["--env", `${EXECUTION_BUDGET_ENV}=${JSON.stringify({ ...budget, startedAt: this.now() })}`] : []),
752
+ "--no-focus",
747
753
  ];
748
754
  const response = await this.herdr.exec(args, this.processOptions(allocation.worktreeCwd, context, HERDR_OPERATION_CAP_MS));
749
755
  if (response.code !== 0 || response.killed) {
@@ -783,8 +789,9 @@ export class HerdrHostRuntime {
783
789
  return await withTransientLaunch(handle, async (launch) => {
784
790
  if (launch.role !== expectedRole)
785
791
  throw new Error("Agent start acquisition returned the wrong Role.");
786
- if (Object.keys(launch.env).length)
792
+ if (Object.keys(launch.env).some((name) => name !== EXECUTION_BUDGET_ENV || !this.executionBudget)) {
787
793
  throw new Error("Herdr agent launch must not receive caller Role environment variables.");
794
+ }
788
795
  const response = await startPiAgent(this.herdr, {
789
796
  name: allocation.agentName,
790
797
  pane: allocation.paneId,
@@ -827,6 +834,15 @@ export class HerdrHostRuntime {
827
834
  if (lifecycle.status === "unknown") {
828
835
  return { outcome: "unknown", diagnostic: "Delivered prompt lifecycle is unknown." };
829
836
  }
837
+ if (lifecycle.status === "working") {
838
+ try {
839
+ await this.delay(STALLED_PROMPT_POLL_MS, context.signal);
840
+ }
841
+ catch (error) {
842
+ return { outcome: this.contextInterrupted(context) ? "interrupted" : "unknown", diagnostic: `Delivered prompt polling failed: ${safeText(error)}` };
843
+ }
844
+ continue;
845
+ }
830
846
  let inspection;
831
847
  try {
832
848
  inspection = await this.inspectInFlightCandidate({ root: allocation.worktreeCwd, task: input.task, attempt: input.attempt }, this.childContext(context, GIT_INSPECTION_CAP_MS));
package/dist/index.d.ts CHANGED
@@ -9,6 +9,7 @@ export { captureWorkingCheckoutBaseline, captureWorkingCheckoutState, prepareWor
9
9
  export declare const PI_SUBAGENT_PROCESS_LEASE = "PI_SUBAGENT_PROCESS_LEASE";
10
10
  export declare const ROLE_MCP_POLICY_FLAG = "pi-subagent-role-mcps";
11
11
  export declare const ROLE_TOOL_POLICY_FLAG = "pi-subagent-role-tools";
12
+ export declare const EXECUTION_BUDGET_FLAG = "pi-subagent-execution-budget";
12
13
  export declare const CHILD_EXCLUDED_TOOL_NAMES: readonly ["delegate_task", "ask_question", "subagent_status", "subagent_resume", "subagent_stage", "subagent_abort"];
13
14
  export declare const CHILD_EXCLUDED_TOOLS: string;
14
15
  export declare const DELEGATE_TASK: {
package/dist/index.js CHANGED
@@ -19,6 +19,7 @@ const ROLE_TOOLS_EXTENSION = fileURLToPath(new URL("../extensions/role-tools.ts"
19
19
  export const PI_SUBAGENT_PROCESS_LEASE = "PI_SUBAGENT_PROCESS_LEASE";
20
20
  export const ROLE_MCP_POLICY_FLAG = "pi-subagent-role-mcps";
21
21
  export const ROLE_TOOL_POLICY_FLAG = "pi-subagent-role-tools";
22
+ export const EXECUTION_BUDGET_FLAG = "pi-subagent-execution-budget";
22
23
  export const CHILD_EXCLUDED_TOOL_NAMES = [
23
24
  "delegate_task",
24
25
  "ask_question",
@@ -45,10 +45,15 @@ async function sharedHistory(base, tip, cwd, signal) {
45
45
  /** Check helper-derived names even after a safely removed checkout is no longer present. */
46
46
  async function ownershipMetadata(root, info, signal) {
47
47
  const canonical = await realpath(root);
48
+ const listed = await requireGit(["worktree", "list", "--porcelain", "-z"], root, signal);
49
+ const primary = listed.split("\0", 1)[0];
50
+ if (!primary?.startsWith("worktree "))
51
+ throw new Error("Primary Git worktree is unavailable.");
52
+ const repoRoot = await realpath(primary.slice("worktree ".length));
48
53
  if (await realpath(await requireGit(["rev-parse", "--show-toplevel"], root, signal)) !== canonical
49
- || info.repoRoot !== canonical || info.cwd !== info.path
54
+ || info.repoRoot !== repoRoot || info.cwd !== info.path
50
55
  || !/^pi-subagent\/subagent-[0-9a-f]{24}$/.test(info.branch)
51
- || info.path !== join(canonical, ".worktrees", basename(info.branch))) {
56
+ || info.path !== join(repoRoot, ".worktrees", basename(info.branch))) {
52
57
  throw new Error("Git worktree is not an exact owned checkout of Main.");
53
58
  }
54
59
  }
@@ -1,5 +1,5 @@
1
1
  import { type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import { type EphemeralSubagentExecutionBudget } from "./ephemeral.ts";
2
+ import type { EphemeralSubagentExecutionBudget } from "./ephemeral.ts";
3
3
  import type { CoordinatorRuntime, OperationContext, TransientLaunchHandle, VerifiedLaunch } from "./runner.ts";
4
4
  import { type ExecuteRequest, type ModelClass, type WorkspaceIdentity } from "./schema.ts";
5
5
  export declare const ISOLATED_MODEL_TASK: {
@@ -3,8 +3,7 @@ import { constants } from "node:fs";
3
3
  import { chmod, lstat, mkdtemp, open, realpath, rmdir, unlink } from "node:fs/promises";
4
4
  import { tmpdir } from "node:os";
5
5
  import { isAbsolute, join, normalize, relative, sep } from "node:path";
6
- import { resolveConfiguredRoleLaunch } from "./index.js";
7
- import { EXECUTION_BUDGET_ENV } from "./ephemeral.js";
6
+ import { EXECUTION_BUDGET_FLAG, resolveConfiguredRoleLaunch } from "./index.js";
8
7
  import { registerModelTask } from "@henryqw/pi-task-models";
9
8
  const PROMPT_FLAG = "--append-system-prompt";
10
9
  const DIRECTORY_MODE = 0o700;
@@ -138,13 +137,14 @@ export class RoleLaunchRuntime {
138
137
  modelClass,
139
138
  model: `${prepared.model.provider}/${prepared.model.id}`,
140
139
  thinkingLevel: prepared.thinkingLevel,
141
- args: Object.freeze([...prepared.args]),
142
- env: Object.freeze({
143
- ...prepared.env,
144
- ...(executionBudget === undefined ? {} : {
145
- [EXECUTION_BUDGET_ENV]: JSON.stringify({ ...executionBudget, startedAt: this.now() }),
146
- }),
147
- }),
140
+ args: Object.freeze([
141
+ ...prepared.args,
142
+ ...(executionBudget === undefined ? [] : [
143
+ `--${EXECUTION_BUDGET_FLAG}`,
144
+ JSON.stringify({ ...executionBudget, startedAt: this.now() }),
145
+ ]),
146
+ ]),
147
+ env: Object.freeze({ ...prepared.env }),
148
148
  tools: Object.freeze([...prepared.tools]),
149
149
  }),
150
150
  prompt: prepared.systemPrompt,
package/dist/runner.d.ts CHANGED
@@ -303,6 +303,13 @@ export declare class IsolatedRunner {
303
303
  }>;
304
304
  invalidIds: string[];
305
305
  }>;
306
+ /** Classify orphaned work under the same repository lease as productive execution.
307
+ * Status remains read-only; Main alone chooses any subsequent action. */
308
+ recoverRepository(root: string): Promise<{
309
+ requests: RunResponse[];
310
+ invalidIds: string[];
311
+ leaseBusy: boolean;
312
+ }>;
306
313
  canFollowup(root: string, requestId: string, taskId: string): boolean;
307
314
  drainFollowups(root: string, requestId: string, taskId: string): string[];
308
315
  queueFollowup(root: string, requestId: string, taskId: string, instruction: string): string;
package/dist/runner.js CHANGED
@@ -2,7 +2,7 @@ import { readFileSync, realpathSync } from "node:fs";
2
2
  import { createChildWorktree, finalizeChildWorktree } from "./worktree.js";
3
3
  import { IntegrationGit } from "./integration-git.js";
4
4
  import { checkBatchPasses, CLEANUP_KINDS, isCleanCommitted, MAX_PERSISTED_RUNTIME_TEXT_BYTES, MAX_POSSIBLE_RESOURCES, parseExecuteRequest, parseIntegrationAction, integrationGenerationPasses, parseResumeRequest, parseStageRequest, reviewEvidencePasses, sameIdentity, RUN_STATE_VERSION, } from "./schema.js";
5
- import { FileRunStore } from "./store.js";
5
+ import { FileRunStore, ProductiveRunLeaseBusyError } from "./store.js";
6
6
  const TRUNCATION_MARKER = "\n[truncated]";
7
7
  const TEXT_TASK_PROMPT_MAX_BYTES = 64 * 1024;
8
8
  const CHANGESET_TASK_PROMPT_MAX_BYTES = 96 * 1024;
@@ -533,6 +533,39 @@ export class IsolatedRunner {
533
533
  })),
534
534
  })) };
535
535
  }
536
+ /** Classify orphaned work under the same repository lease as productive execution.
537
+ * Status remains read-only; Main alone chooses any subsequent action. */
538
+ async recoverRepository(root) {
539
+ root = realpathSync.native(root);
540
+ let leaseBusy = false;
541
+ let inventory;
542
+ try {
543
+ inventory = await this.withProductiveRun(root, async (lifecycle) => await this.store.withLock(root, async () => {
544
+ const listed = await this.listRequests(root);
545
+ for (const request of listed.requests) {
546
+ if (request.status !== "pending" && request.status !== "running")
547
+ continue;
548
+ const handle = await this.store.load(root, request.id);
549
+ if (this.recoverInterrupted(handle.state))
550
+ await handle.save();
551
+ }
552
+ return listed;
553
+ }, { productiveRunLease: lifecycle.lease }));
554
+ }
555
+ catch (error) {
556
+ if (!(error instanceof ProductiveRunLeaseBusyError))
557
+ throw error;
558
+ leaseBusy = true;
559
+ inventory = await this.listRequests(root);
560
+ }
561
+ const requests = [];
562
+ for (const request of inventory.requests) {
563
+ if (["pending", "running", "needs_attention", "completed · retained"].includes(request.status)) {
564
+ requests.push(await this.status(request.id, root));
565
+ }
566
+ }
567
+ return { requests, invalidIds: inventory.invalidIds, leaseBusy };
568
+ }
536
569
  canFollowup(root, requestId, taskId) {
537
570
  const active = this.activeControl(realpathSync.native(root), requestId, taskId);
538
571
  return active !== undefined && active.task.status === "working" && !active.control.isSealed;
@@ -564,12 +597,13 @@ export class IsolatedRunner {
564
597
  if (this.productiveLifecycles.has(root)) {
565
598
  throw new Error("Another Pi Subagent productive request is active in this runner.");
566
599
  }
567
- const lifecycle = { lease, stopped: false };
600
+ const lifecycle = { lease, stages: new Set(), stopped: false };
568
601
  this.productiveLifecycles.set(root, lifecycle);
569
602
  try {
570
603
  return await operation(lifecycle);
571
604
  }
572
605
  finally {
606
+ await Promise.allSettled(lifecycle.stages);
573
607
  this.productiveLifecycles.delete(root);
574
608
  }
575
609
  });
@@ -756,10 +790,11 @@ export class IsolatedRunner {
756
790
  async stage(value, root, outerSignal) {
757
791
  const action = parseStageRequest(value);
758
792
  root = realpathSync.native(root);
759
- return await this.withProductiveRun(root, async (lifecycle) => {
760
- const handle = await this.store.withLock(root, async () => await this.store.load(root, action.id), { productiveRunLease: lifecycle.lease });
793
+ const stageSelected = async (handle, duringWave) => {
761
794
  const state = handle.state;
762
- if (terminal(state) || state.status !== "needs_attention" || (state.integration.refresh && state.integration.refresh.status !== "ready")) {
795
+ if (terminal(state) || (state.status !== "needs_attention"
796
+ && !(duringWave && state.status === "running" && (action.action === "stage" || action.action === "resolve")))
797
+ || (state.integration.refresh && state.integration.refresh.status !== "ready")) {
763
798
  throw new Error("Request is not waiting for Main staging or refresh allocation remains uncertain.");
764
799
  }
765
800
  const candidate = state.integration.candidates.find((item) => item.taskId === action.taskId && item.attempt === action.attempt
@@ -905,6 +940,7 @@ export class IsolatedRunner {
905
940
  if (generation && generation.status !== "staging" && generation.status !== "conflict")
906
941
  throw new Error("Stage action has a stale generation.");
907
942
  const previousStage = generation?.stages.at(-1);
943
+ const pendingAllocation = action.action === "resolve" && previousStage?.status === "pending";
908
944
  const expected = previousStage?.status === "staged"
909
945
  ? previousStage.tip : previousStage?.onto ?? generation?.integrationBase;
910
946
  if (action.action === "stage" && generation?.stages.some((stage) => stage.status !== "staged")) {
@@ -912,7 +948,7 @@ export class IsolatedRunner {
912
948
  }
913
949
  if (action.action === "resolve") {
914
950
  const pending = generation?.stages.at(-1);
915
- if (!pending || !["staging", "conflict"].includes(pending.status)
951
+ if (!pending || !["pending", "staging", "conflict"].includes(pending.status)
916
952
  || pending.taskId !== action.taskId || pending.attempt !== action.attempt
917
953
  || !sameIdentity(pending.source, action.candidate)
918
954
  || !sameIdentity(pending.onto, action.expectedTip))
@@ -939,6 +975,18 @@ export class IsolatedRunner {
939
975
  const actualMain = await scope.call((context) => this.gitRuntime.inspectMain({ root }, context));
940
976
  if (!sameIdentity(actualMain, state.main))
941
977
  throw new Error("Main changed or became dirty; staging is blocked.");
978
+ if (pendingAllocation) {
979
+ if (!generation?.worktree || generation.status !== "staging" || generation.stages.length !== 1
980
+ || !sameIdentity(action.expectedTip, generation.integrationBase)) {
981
+ throw new Error("Pending integration allocation is not an exact initial stage.");
982
+ }
983
+ const checkout = generation.worktree;
984
+ const base = await scope.call((context) => this.integrationGit.inspectCombined(root, checkout, state.main, [], context.signal));
985
+ if (!sameIdentity(base, generation.integrationBase))
986
+ throw new Error("Pending integration checkout is not the clean recorded base.");
987
+ previousStage.status = "staging";
988
+ await this.saveProductive(handle); // Explicit resolve proves allocation; merge intent precedes the first merge.
989
+ }
942
990
  if (!generation) {
943
991
  if (action.action !== "stage")
944
992
  throw new Error("No integration generation exists to resolve.");
@@ -983,7 +1031,7 @@ export class IsolatedRunner {
983
1031
  }
984
1032
  await this.saveProductive(handle); // Exact candidate and previous tip before merge.
985
1033
  }
986
- const outcome = await scope.call((context) => action.action === "stage"
1034
+ const outcome = await scope.call((context) => action.action === "stage" || pendingAllocation
987
1035
  ? this.integrationGit.stage(root, worktree, state.main, receipts, worker, candidate.tip, context.signal)
988
1036
  : this.integrationGit.reconcileStage(root, worktree, state.main, receipts, worker, candidate.tip, context.signal));
989
1037
  if (outcome.outcome === "ready" && outcome.value !== "not_started") {
@@ -1008,6 +1056,28 @@ export class IsolatedRunner {
1008
1056
  // Unproved outcomes keep the intent; never replay a merge automatically.
1009
1057
  await this.saveProductive(handle);
1010
1058
  return this.response(state);
1059
+ };
1060
+ const active = this.productiveLifecycles.get(root);
1061
+ if (active?.handle?.state.request.id === action.id) {
1062
+ if (active.stopped)
1063
+ throw new DurableRunStopped(active.handle.state);
1064
+ if (action.action !== "stage" && action.action !== "resolve") {
1065
+ throw new Error("Wait for active workers before revising or rejecting a candidate.");
1066
+ }
1067
+ if (active.stages.size)
1068
+ throw new Error("Another stage or resolution is active; retry after it finishes.");
1069
+ const stage = stageSelected(active.handle, true);
1070
+ active.stages.add(stage);
1071
+ try {
1072
+ return await stage;
1073
+ }
1074
+ finally {
1075
+ active.stages.delete(stage);
1076
+ }
1077
+ }
1078
+ return await this.withProductiveRun(root, async (lifecycle) => {
1079
+ const handle = await this.store.withLock(root, async () => await this.store.load(root, action.id), { productiveRunLease: lifecycle.lease });
1080
+ return await stageSelected(handle, false);
1011
1081
  });
1012
1082
  }
1013
1083
  stageReceipts(generation) {
@@ -1581,6 +1651,8 @@ export class IsolatedRunner {
1581
1651
  }
1582
1652
  async run(handle, scope, forceTextTaskId, stagedSnapshot) {
1583
1653
  const state = handle.state;
1654
+ const lifecycle = this.productiveLifecycles.get(state.root);
1655
+ lifecycle.handle = handle;
1584
1656
  let saveOnExit = true;
1585
1657
  state.status = "running";
1586
1658
  state.updatedAt = this.coordinatorRuntime.now();
@@ -1653,6 +1725,10 @@ export class IsolatedRunner {
1653
1725
  const dispatched = await Promise.allSettled(ready.map(async (task) => {
1654
1726
  try {
1655
1727
  await this.dispatchTask(handle, task, scope);
1728
+ if (task.kind === "changeset" && task.status === "ready_to_integrate") {
1729
+ this.retainCandidate(state, task);
1730
+ await this.saveProductive(handle);
1731
+ }
1656
1732
  }
1657
1733
  catch (error) {
1658
1734
  this.rethrowStopped(error);
@@ -1681,9 +1757,6 @@ export class IsolatedRunner {
1681
1757
  forceTextTaskId = undefined;
1682
1758
  continue;
1683
1759
  }
1684
- for (const task of ready)
1685
- if (task.kind === "changeset")
1686
- this.retainCandidate(state, task);
1687
1760
  wave.status = "needs_attention";
1688
1761
  state.status = "needs_attention";
1689
1762
  await this.saveProductive(handle);
@@ -1712,9 +1785,15 @@ export class IsolatedRunner {
1712
1785
  }
1713
1786
  finally {
1714
1787
  this.closeRequestControls(state.root, state.request.id);
1715
- if (saveOnExit) {
1716
- state.updatedAt = this.coordinatorRuntime.now();
1717
- await this.saveProductive(handle);
1788
+ try {
1789
+ if (saveOnExit) {
1790
+ state.updatedAt = this.coordinatorRuntime.now();
1791
+ await this.saveProductive(handle);
1792
+ }
1793
+ }
1794
+ finally {
1795
+ if (lifecycle.handle === handle)
1796
+ delete lifecycle.handle;
1718
1797
  }
1719
1798
  }
1720
1799
  }
@@ -2279,6 +2358,8 @@ export class IsolatedRunner {
2279
2358
  tip: attempt.candidate, checks: attempt.preliminaryChecks,
2280
2359
  ...(request.judgment ? { review: attempt.preliminaryReview } : {}), worker: "retained",
2281
2360
  });
2361
+ state.integration.candidates.sort((a, b) => state.tasks.findIndex((item) => item.taskId === a.taskId)
2362
+ - state.tasks.findIndex((item) => item.taskId === b.taskId));
2282
2363
  }
2283
2364
  async verifyRetainedTask(handle, task, scope) {
2284
2365
  const attempt = latestAttempt(task);
package/dist/store.d.ts CHANGED
@@ -1,5 +1,8 @@
1
1
  import { type RunState } from "./schema.ts";
2
2
  declare const productiveRunLeaseBrand: unique symbol;
3
+ export declare class ProductiveRunLeaseBusyError extends Error {
4
+ constructor();
5
+ }
3
6
  export interface ProductiveRunLease {
4
7
  readonly [productiveRunLeaseBrand]: true;
5
8
  }
package/dist/store.js CHANGED
@@ -24,6 +24,11 @@ const LIFECYCLE_LOCK_OPTIONS = {
24
24
  retries: { forever: true, factor: 1, minTimeout: 25, maxTimeout: 25 },
25
25
  };
26
26
  const productiveRunLeaseBrand = Symbol("productiveRunLease");
27
+ export class ProductiveRunLeaseBusyError extends Error {
28
+ constructor() {
29
+ super("Another Pi Subagent productive request is active in this repository.");
30
+ }
31
+ }
27
32
  function isMissing(error) {
28
33
  return Boolean(error && typeof error === "object" && error.code === "ENOENT");
29
34
  }
@@ -127,7 +132,7 @@ export class FileRunStore {
127
132
  }
128
133
  catch (error) {
129
134
  if (isLocked(error)) {
130
- throw new Error("Another Pi Subagent productive request is active in this repository.");
135
+ throw new ProductiveRunLeaseBusyError();
131
136
  }
132
137
  throw error;
133
138
  }
@@ -46,8 +46,8 @@ export declare function inspectIndexFlags(cwd: string, run?: GitRunner, signal?:
46
46
  hidden: boolean;
47
47
  failure?: string;
48
48
  }>;
49
- /** Proves a worktree has no tracked, untracked, ignored, index-hidden, or nested submodule work. */
50
- export declare function inspectWorktreeDirty(cwd: string, run?: GitRunner): Promise<WorktreeDirtyInspection>;
49
+ /** Proves a worktree has no tracked, untracked, index-hidden, or nested submodule work; cleanup also checks ignored files. */
50
+ export declare function inspectWorktreeDirty(cwd: string, run?: GitRunner, includeIgnored?: boolean): Promise<WorktreeDirtyInspection>;
51
51
  /**
52
52
  * Inspects and possibly prunes a child worktree after it finishes. Commit count
53
53
  * reads the dedicated branch and refuses to prune when checkout HEAD no longer
package/dist/worktree.js CHANGED
@@ -170,12 +170,12 @@ export async function inspectIndexFlags(cwd, run = runGit, signal) {
170
170
  return { hidden: false, failure: `ls-files exit ${flags.code}: ${flags.stderr.trim().slice(0, 200)}` };
171
171
  return { hidden: flags.stdout.split("\0").some((entry) => /^(?:[a-z]|S) /.test(entry)) };
172
172
  }
173
- async function inspectDirty(run, cwd) {
173
+ async function inspectDirty(run, cwd, includeIgnored = true) {
174
174
  const refreshed = await run(["update-index", "--really-refresh"], cwd);
175
175
  if (refreshed.code !== 0 && refreshed.code !== 1) {
176
176
  return { dirty: false, failure: `update-index exit ${refreshed.code}: ${refreshed.stderr.trim().slice(0, 200)}` };
177
177
  }
178
- const status = await run(["status", "--porcelain", "--untracked-files=all", "--ignored=matching", "--ignore-submodules=none"], cwd);
178
+ const status = await run(["status", "--porcelain", "--untracked-files=all", ...(includeIgnored ? ["--ignored=matching"] : []), "--ignore-submodules=none"], cwd);
179
179
  if (status.code !== 0)
180
180
  return { dirty: false, failure: `status exit ${status.code}: ${status.stderr.trim().slice(0, 200)}` };
181
181
  if (refreshed.code === 1 || status.stdout.trim())
@@ -187,9 +187,9 @@ async function inspectDirty(run, cwd) {
187
187
  return { dirty: false, failure: "assume-unchanged or skip-worktree index entries remain" };
188
188
  return { dirty: false };
189
189
  }
190
- /** Proves a worktree has no tracked, untracked, ignored, index-hidden, or nested submodule work. */
191
- export async function inspectWorktreeDirty(cwd, run = runGit) {
192
- const root = await inspectDirty(run, cwd);
190
+ /** Proves a worktree has no tracked, untracked, index-hidden, or nested submodule work; cleanup also checks ignored files. */
191
+ export async function inspectWorktreeDirty(cwd, run = runGit, includeIgnored = true) {
192
+ const root = await inspectDirty(run, cwd, includeIgnored);
193
193
  if (root.dirty || root.failure)
194
194
  return root;
195
195
  const modules = await run(["submodule", "status", "--recursive"], cwd);
@@ -204,14 +204,14 @@ export async function inspectWorktreeDirty(cwd, run = runGit) {
204
204
  if (!paths.length)
205
205
  return { dirty: false, failure: "initialized submodule paths unavailable" };
206
206
  for (const path of paths) {
207
- const nested = await inspectDirty(run, path);
207
+ const nested = await inspectDirty(run, path, includeIgnored);
208
208
  if (nested.failure)
209
209
  return { dirty: false, failure: `submodule ${path}: ${nested.failure}` };
210
210
  if (nested.dirty)
211
211
  return { dirty: true };
212
212
  }
213
213
  }
214
- const rechecked = await inspectDirty(run, cwd);
214
+ const rechecked = await inspectDirty(run, cwd, includeIgnored);
215
215
  return initializedSubmodules ? { ...rechecked, initializedSubmodules } : rechecked;
216
216
  }
217
217
  /**
@@ -232,7 +232,7 @@ export async function finalizeChildWorktree(info, run = runGit) {
232
232
  const countFailure = counted.code !== 0
233
233
  ? `rev-list exit ${counted.code}: ${counted.stderr.trim().slice(0, 200)}`
234
234
  : Number.isNaN(commits) ? "rev-list produced non-numeric output" : undefined;
235
- const inspection = checkoutExists ? await inspectDirty(run, info.path) : undefined;
235
+ const inspection = checkoutExists ? await inspectDirty(run, info.path, true) : undefined;
236
236
  const measurements = {
237
237
  ...(countFailure === undefined ? { commits } : {}),
238
238
  ...(inspection?.failure === undefined && inspection !== undefined ? { dirty: inspection.dirty } : {}),