@henryqw/pi-subagent 22.1.0 → 22.2.0
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 +13 -8
- package/dist/git-runtime.js +21 -18
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/integration-git.js +4 -2
- package/dist/launch-runtime.d.ts +1 -1
- package/dist/launch-runtime.js +9 -9
- package/dist/runner.d.ts +7 -0
- package/dist/runner.js +87 -13
- package/dist/store.d.ts +3 -0
- package/dist/store.js +6 -1
- package/dist/worktree.d.ts +1 -1
- package/dist/worktree.js +6 -6
- package/docs/worker-lifecycle.html +92 -0
- package/docs/worker-lifecycle.svg +80 -0
- package/extensions/isolated.ts +64 -1
- package/extensions/role-tools.ts +8 -2
- package/extensions/subagent-command.ts +15 -6
- package/extensions/subagent.ts +45 -1
- package/package.json +1 -1
- package/skills/pi-subagent/SKILL.md +3 -3
- package/docs/delegation-routing.html +0 -89
- package/docs/delegation-routing.svg +0 -78
package/README.md
CHANGED
|
@@ -21,7 +21,9 @@ pi install npm:pi-mcp-adapter
|
|
|
21
21
|
|
|
22
22
|
## Use
|
|
23
23
|
|
|
24
|
-

|
|
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 |
|
|
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 `
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
package/dist/git-runtime.js
CHANGED
|
@@ -339,24 +339,27 @@ export class CheckedGitRuntime {
|
|
|
339
339
|
return worktree;
|
|
340
340
|
}
|
|
341
341
|
async inspectInFlightWorkspace(cwd, context) {
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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");
|
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",
|
package/dist/integration-git.js
CHANGED
|
@@ -45,10 +45,12 @@ 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 first = (await requireGit(["worktree", "list", "--porcelain", "-z"], root, signal)).split("\0")[0];
|
|
49
|
+
const primary = first.startsWith("worktree ") ? await realpath(first.slice("worktree ".length)) : "";
|
|
48
50
|
if (await realpath(await requireGit(["rev-parse", "--show-toplevel"], root, signal)) !== canonical
|
|
49
|
-
|| info.repoRoot !==
|
|
51
|
+
|| info.repoRoot !== primary || info.cwd !== info.path
|
|
50
52
|
|| !/^pi-subagent\/subagent-[0-9a-f]{24}$/.test(info.branch)
|
|
51
|
-
|| info.path !== join(
|
|
53
|
+
|| info.path !== join(primary, ".worktrees", basename(info.branch))) {
|
|
52
54
|
throw new Error("Git worktree is not an exact owned checkout of Main.");
|
|
53
55
|
}
|
|
54
56
|
}
|
package/dist/launch-runtime.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import {
|
|
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: {
|
package/dist/launch-runtime.js
CHANGED
|
@@ -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([
|
|
142
|
-
|
|
143
|
-
...
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
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"
|
|
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
|
|
@@ -912,7 +947,7 @@ export class IsolatedRunner {
|
|
|
912
947
|
}
|
|
913
948
|
if (action.action === "resolve") {
|
|
914
949
|
const pending = generation?.stages.at(-1);
|
|
915
|
-
if (!pending || !["staging", "conflict"].includes(pending.status)
|
|
950
|
+
if (!pending || !["pending", "staging", "conflict"].includes(pending.status)
|
|
916
951
|
|| pending.taskId !== action.taskId || pending.attempt !== action.attempt
|
|
917
952
|
|| !sameIdentity(pending.source, action.candidate)
|
|
918
953
|
|| !sameIdentity(pending.onto, action.expectedTip))
|
|
@@ -983,9 +1018,15 @@ export class IsolatedRunner {
|
|
|
983
1018
|
}
|
|
984
1019
|
await this.saveProductive(handle); // Exact candidate and previous tip before merge.
|
|
985
1020
|
}
|
|
986
|
-
|
|
1021
|
+
let outcome = await scope.call((context) => action.action === "stage"
|
|
987
1022
|
? this.integrationGit.stage(root, worktree, state.main, receipts, worker, candidate.tip, context.signal)
|
|
988
1023
|
: this.integrationGit.reconcileStage(root, worktree, state.main, receipts, worker, candidate.tip, context.signal));
|
|
1024
|
+
if (action.action === "resolve" && stage.status === "pending"
|
|
1025
|
+
&& outcome.outcome === "ready" && outcome.value === "not_started") {
|
|
1026
|
+
stage.status = "staging";
|
|
1027
|
+
await this.saveProductive(handle); // Read-only proof before Main's explicit merge request.
|
|
1028
|
+
outcome = await scope.call((context) => this.integrationGit.stage(root, worktree, state.main, receipts, worker, candidate.tip, context.signal));
|
|
1029
|
+
}
|
|
989
1030
|
if (outcome.outcome === "ready" && outcome.value !== "not_started") {
|
|
990
1031
|
stage.status = "staged";
|
|
991
1032
|
stage.tip = outcome.value.tip;
|
|
@@ -1008,6 +1049,28 @@ export class IsolatedRunner {
|
|
|
1008
1049
|
// Unproved outcomes keep the intent; never replay a merge automatically.
|
|
1009
1050
|
await this.saveProductive(handle);
|
|
1010
1051
|
return this.response(state);
|
|
1052
|
+
};
|
|
1053
|
+
const active = this.productiveLifecycles.get(root);
|
|
1054
|
+
if (active?.handle?.state.request.id === action.id) {
|
|
1055
|
+
if (active.stopped)
|
|
1056
|
+
throw new DurableRunStopped(active.handle.state);
|
|
1057
|
+
if (action.action !== "stage" && action.action !== "resolve") {
|
|
1058
|
+
throw new Error("Wait for active workers before revising or rejecting a candidate.");
|
|
1059
|
+
}
|
|
1060
|
+
if (active.stages.size)
|
|
1061
|
+
throw new Error("Another stage or resolution is active; retry after it finishes.");
|
|
1062
|
+
const stage = stageSelected(active.handle, true);
|
|
1063
|
+
active.stages.add(stage);
|
|
1064
|
+
try {
|
|
1065
|
+
return await stage;
|
|
1066
|
+
}
|
|
1067
|
+
finally {
|
|
1068
|
+
active.stages.delete(stage);
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
return await this.withProductiveRun(root, async (lifecycle) => {
|
|
1072
|
+
const handle = await this.store.withLock(root, async () => await this.store.load(root, action.id), { productiveRunLease: lifecycle.lease });
|
|
1073
|
+
return await stageSelected(handle, false);
|
|
1011
1074
|
});
|
|
1012
1075
|
}
|
|
1013
1076
|
stageReceipts(generation) {
|
|
@@ -1581,6 +1644,8 @@ export class IsolatedRunner {
|
|
|
1581
1644
|
}
|
|
1582
1645
|
async run(handle, scope, forceTextTaskId, stagedSnapshot) {
|
|
1583
1646
|
const state = handle.state;
|
|
1647
|
+
const lifecycle = this.productiveLifecycles.get(state.root);
|
|
1648
|
+
lifecycle.handle = handle;
|
|
1584
1649
|
let saveOnExit = true;
|
|
1585
1650
|
state.status = "running";
|
|
1586
1651
|
state.updatedAt = this.coordinatorRuntime.now();
|
|
@@ -1653,6 +1718,10 @@ export class IsolatedRunner {
|
|
|
1653
1718
|
const dispatched = await Promise.allSettled(ready.map(async (task) => {
|
|
1654
1719
|
try {
|
|
1655
1720
|
await this.dispatchTask(handle, task, scope);
|
|
1721
|
+
if (task.kind === "changeset" && task.status === "ready_to_integrate") {
|
|
1722
|
+
this.retainCandidate(state, task);
|
|
1723
|
+
await this.saveProductive(handle);
|
|
1724
|
+
}
|
|
1656
1725
|
}
|
|
1657
1726
|
catch (error) {
|
|
1658
1727
|
this.rethrowStopped(error);
|
|
@@ -1681,9 +1750,6 @@ export class IsolatedRunner {
|
|
|
1681
1750
|
forceTextTaskId = undefined;
|
|
1682
1751
|
continue;
|
|
1683
1752
|
}
|
|
1684
|
-
for (const task of ready)
|
|
1685
|
-
if (task.kind === "changeset")
|
|
1686
|
-
this.retainCandidate(state, task);
|
|
1687
1753
|
wave.status = "needs_attention";
|
|
1688
1754
|
state.status = "needs_attention";
|
|
1689
1755
|
await this.saveProductive(handle);
|
|
@@ -1712,9 +1778,15 @@ export class IsolatedRunner {
|
|
|
1712
1778
|
}
|
|
1713
1779
|
finally {
|
|
1714
1780
|
this.closeRequestControls(state.root, state.request.id);
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1781
|
+
try {
|
|
1782
|
+
if (saveOnExit) {
|
|
1783
|
+
state.updatedAt = this.coordinatorRuntime.now();
|
|
1784
|
+
await this.saveProductive(handle);
|
|
1785
|
+
}
|
|
1786
|
+
}
|
|
1787
|
+
finally {
|
|
1788
|
+
if (lifecycle.handle === handle)
|
|
1789
|
+
delete lifecycle.handle;
|
|
1718
1790
|
}
|
|
1719
1791
|
}
|
|
1720
1792
|
}
|
|
@@ -2279,6 +2351,8 @@ export class IsolatedRunner {
|
|
|
2279
2351
|
tip: attempt.candidate, checks: attempt.preliminaryChecks,
|
|
2280
2352
|
...(request.judgment ? { review: attempt.preliminaryReview } : {}), worker: "retained",
|
|
2281
2353
|
});
|
|
2354
|
+
state.integration.candidates.sort((a, b) => state.tasks.findIndex((item) => item.taskId === a.taskId)
|
|
2355
|
+
- state.tasks.findIndex((item) => item.taskId === b.taskId));
|
|
2282
2356
|
}
|
|
2283
2357
|
async verifyRetainedTask(handle, task, scope) {
|
|
2284
2358
|
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
|
|
135
|
+
throw new ProductiveRunLeaseBusyError();
|
|
131
136
|
}
|
|
132
137
|
throw error;
|
|
133
138
|
}
|
package/dist/worktree.d.ts
CHANGED
|
@@ -47,7 +47,7 @@ export declare function inspectIndexFlags(cwd: string, run?: GitRunner, signal?:
|
|
|
47
47
|
failure?: string;
|
|
48
48
|
}>;
|
|
49
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>;
|
|
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())
|
|
@@ -188,8 +188,8 @@ async function inspectDirty(run, cwd) {
|
|
|
188
188
|
return { dirty: false };
|
|
189
189
|
}
|
|
190
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);
|
|
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
|
/**
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
5
|
+
<title>Checked subagent lifecycle and coordinator recovery</title>
|
|
6
|
+
<link href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=Geist:wght@400;500;600&family=Geist+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
|
7
|
+
<style>
|
|
8
|
+
* { box-sizing: border-box; } body { margin: 0; padding: 32px; background: #f0eee9; color: #101828; font-family: 'Geist', sans-serif; }
|
|
9
|
+
main { max-width: 1280px; margin: auto; } h1 { font: 400 28px 'Instrument Serif', serif; margin: 0 0 16px; }
|
|
10
|
+
svg { display: block; width: 100%; height: auto; }
|
|
11
|
+
</style>
|
|
12
|
+
</head>
|
|
13
|
+
<body><main><h1>Checked subagent lifecycle and coordinator recovery</h1>
|
|
14
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720" role="img" aria-labelledby="worker-lifecycle-title worker-lifecycle-desc">
|
|
15
|
+
<title id="worker-lifecycle-title">Checked subagent lifecycle and coordinator recovery</title>
|
|
16
|
+
<desc id="worker-lifecycle-desc">Main delegates to the coordinator extension in its own Pi process. The coordinator saves state and launches a Herdr subagent in an isolated worktree. After the subagent commits, the coordinator verifies and saves a checked candidate; Main explicitly stages and promotes only after combined validation. A worker failure waits for the wave to settle before an attention follow-up. If the coordinator process stops, no follow-up is guaranteed; the last durable state remains for status inspection and deliberate recovery without automatic replay.</desc>
|
|
17
|
+
<defs>
|
|
18
|
+
<style>@import url('https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=Geist:wght@400;500;600&family=Geist+Mono:wght@400;500;600&display=swap');</style>
|
|
19
|
+
<marker id="worker-arrow" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto"><polygon points="0 0,8 3,0 6" fill="#4c5665"/></marker>
|
|
20
|
+
<marker id="worker-arrow-accent" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto"><polygon points="0 0,8 3,0 6" fill="#1d4ed8"/></marker>
|
|
21
|
+
</defs>
|
|
22
|
+
<rect width="1280" height="720" fill="#f0eee9"/>
|
|
23
|
+
<text x="48" y="60" fill="#6a7282" font-family="'Geist Mono',monospace" font-size="8" letter-spacing="1.5">PI SUBAGENT / ONE ISOLATED WORKER</text>
|
|
24
|
+
<text x="48" y="99" fill="#101828" font-family="'Instrument Serif',serif" font-size="28">A worker commits. Main decides what is integrated.</text>
|
|
25
|
+
<text x="48" y="126" fill="#4c5665" font-family="'Geist',sans-serif" font-size="12">The coordinator is extension code inside Main's Pi process, not another agent.</text>
|
|
26
|
+
<rect x="64" y="158" width="1138" height="58" rx="8" fill="#e6e4de"/>
|
|
27
|
+
<text x="84" y="181" fill="#101828" font-family="'Geist',sans-serif" font-size="12" font-weight="600">One worker shown; siblings can keep working after this candidate is ready.</text>
|
|
28
|
+
<text x="84" y="200" fill="#4c5665" font-family="'Geist',sans-serif" font-size="12">State is persisted by the coordinator. Herdr agent status alone does not prove a clean, checked candidate.</text>
|
|
29
|
+
<!-- Connectors before nodes. Primary rail is the checked path; dashed paths retain uncertainty. -->
|
|
30
|
+
<line x1="234" y1="350" x2="306" y2="350" stroke="#4c5665" marker-end="url(#worker-arrow)"/>
|
|
31
|
+
<line x1="476" y1="350" x2="548" y2="350" stroke="#4c5665" marker-end="url(#worker-arrow)"/>
|
|
32
|
+
<line x1="718" y1="350" x2="790" y2="350" stroke="#4c5665" marker-end="url(#worker-arrow)"/>
|
|
33
|
+
<line x1="960" y1="350" x2="1032" y2="350" stroke="#1d4ed8" stroke-width="1.4" marker-end="url(#worker-arrow-accent)"/>
|
|
34
|
+
<path d="M633 405 V434 Q633 442 625 442 H408 Q400 442 400 450 V512" fill="none" stroke="#4c5665" stroke-dasharray="5,4" marker-end="url(#worker-arrow)"/>
|
|
35
|
+
<line x1="500" y1="546" x2="526" y2="546" stroke="#4c5665" marker-end="url(#worker-arrow)"/>
|
|
36
|
+
<line x1="875" y1="405" x2="875" y2="512" stroke="#4c5665" stroke-dasharray="5,4" marker-end="url(#worker-arrow)"/>
|
|
37
|
+
<line x1="968" y1="546" x2="994" y2="546" stroke="#4c5665" marker-end="url(#worker-arrow)"/>
|
|
38
|
+
<!-- Transition labels sit above, not on, their strokes. -->
|
|
39
|
+
<g fill="#4c5665" font-family="'Geist Mono',monospace" font-size="8" text-anchor="middle">
|
|
40
|
+
<rect x="242" y="324" width="56" height="12" rx="2" fill="#f0eee9"/><text x="270" y="333">START</text>
|
|
41
|
+
<rect x="484" y="324" width="56" height="12" rx="2" fill="#f0eee9"/><text x="512" y="333">LAUNCH</text>
|
|
42
|
+
<rect x="726" y="324" width="56" height="12" rx="2" fill="#f0eee9"/><text x="754" y="333">CHECK</text>
|
|
43
|
+
<rect x="968" y="324" width="56" height="12" rx="2" fill="#f0eee9"/><text x="996" y="333" fill="#1d4ed8">READY</text>
|
|
44
|
+
</g>
|
|
45
|
+
<!-- Five phases, with ownership above each. -->
|
|
46
|
+
<g fill="#6a7282" font-family="'Geist Mono',monospace" font-size="8" letter-spacing="1" text-anchor="middle">
|
|
47
|
+
<text x="149" y="272">MAIN AGENT</text><text x="391" y="272">COORDINATOR</text>
|
|
48
|
+
<text x="633" y="272">HERDR SUBAGENT</text><text x="875" y="272">COORDINATOR</text>
|
|
49
|
+
<text x="1117" y="272">MAIN AGENT</text>
|
|
50
|
+
</g>
|
|
51
|
+
<g fill="#ffffff" stroke="#101828">
|
|
52
|
+
<rect x="64" y="295" width="170" height="110" rx="8"/>
|
|
53
|
+
<rect x="306" y="295" width="170" height="110" rx="8"/>
|
|
54
|
+
<rect x="548" y="295" width="170" height="110" rx="8"/>
|
|
55
|
+
<rect x="790" y="295" width="170" height="110" rx="8" fill="#1d4ed8" fill-opacity="0.08" stroke="#1d4ed8"/>
|
|
56
|
+
<rect x="1032" y="295" width="170" height="110" rx="8"/>
|
|
57
|
+
</g>
|
|
58
|
+
<g fill="#101828" font-family="'Geist',sans-serif" font-size="12" font-weight="600" text-anchor="middle">
|
|
59
|
+
<text x="149" y="339">Delegate task</text><text x="391" y="339">Save + launch</text>
|
|
60
|
+
<text x="633" y="339">Implement + commit</text><text x="875" y="339">Check + seal</text>
|
|
61
|
+
<text x="1117" y="339">Stage + promote</text>
|
|
62
|
+
</g>
|
|
63
|
+
<g fill="#4c5665" font-family="'Geist',sans-serif" font-size="11" text-anchor="middle">
|
|
64
|
+
<text x="149" y="370">Bounded request</text><text x="391" y="370">Durable ID + lease</text>
|
|
65
|
+
<text x="633" y="370">Isolated worktree</text><text x="875" y="370">Clean tip + task checks</text>
|
|
66
|
+
<text x="1117" y="370">Combined validation first</text>
|
|
67
|
+
</g>
|
|
68
|
+
<!-- Recovery bands: an incomplete worker and a stopped coordinator are distinct. -->
|
|
69
|
+
<rect x="286" y="468" width="448" height="146" rx="8" fill="#e6e4de"/>
|
|
70
|
+
<rect x="756" y="468" width="446" height="146" rx="8" fill="#e6e4de"/>
|
|
71
|
+
<g fill="#6a7282" font-family="'Geist Mono',monospace" font-size="8" letter-spacing="1">
|
|
72
|
+
<text x="310" y="492">WORKER FAILURE</text><text x="778" y="492">COORDINATOR STOPS / ANY PHASE</text>
|
|
73
|
+
</g>
|
|
74
|
+
<g fill="#ffffff" stroke="#4c5665" stroke-dasharray="5,4">
|
|
75
|
+
<rect x="310" y="512" width="190" height="68" rx="8"/>
|
|
76
|
+
<rect x="526" y="512" width="192" height="68" rx="8"/>
|
|
77
|
+
<rect x="778" y="512" width="190" height="68" rx="8"/>
|
|
78
|
+
<rect x="994" y="512" width="190" height="68" rx="8"/>
|
|
79
|
+
</g>
|
|
80
|
+
<g fill="#101828" font-family="'Geist',sans-serif" font-size="12" font-weight="600" text-anchor="middle">
|
|
81
|
+
<text x="405" y="540">No checked candidate</text><text x="622" y="540">Wave needs attention</text>
|
|
82
|
+
<text x="873" y="540">No guaranteed message</text><text x="1089" y="540">Inspect saved state</text>
|
|
83
|
+
</g>
|
|
84
|
+
<g fill="#4c5665" font-family="'Geist',sans-serif" font-size="10" text-anchor="middle">
|
|
85
|
+
<text x="405" y="560">Failure retained</text><text x="622" y="560">Follow-up when settled</text>
|
|
86
|
+
<text x="873" y="560">Last write survives</text><text x="1089" y="560">Restart; recover deliberately</text>
|
|
87
|
+
</g>
|
|
88
|
+
<line x1="48" y1="648" x2="1232" y2="648" stroke="#101828" stroke-opacity="0.12"/>
|
|
89
|
+
<text x="48" y="675" fill="#6a7282" font-family="'Geist Mono',monospace" font-size="8" letter-spacing="1">GUARD</text>
|
|
90
|
+
<text x="128" y="675" fill="#101828" font-family="'Geist',sans-serif" font-size="12">Status reports exact saved evidence; uncertain steps are never replayed automatically.</text>
|
|
91
|
+
<text x="128" y="696" fill="#4c5665" font-family="'Geist',sans-serif" font-size="11">A ready candidate may be staged while siblings run. Promotion still requires full combined validation.</text>
|
|
92
|
+
</svg></main></body></html>
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720" role="img" aria-labelledby="worker-lifecycle-title worker-lifecycle-desc">
|
|
3
|
+
<title id="worker-lifecycle-title">Checked subagent lifecycle and coordinator recovery</title>
|
|
4
|
+
<desc id="worker-lifecycle-desc">Main delegates to the coordinator extension in its own Pi process. The coordinator saves state and launches a Herdr subagent in an isolated worktree. After the subagent commits, the coordinator verifies and saves a checked candidate; Main explicitly stages and promotes only after combined validation. A worker failure waits for the wave to settle before an attention follow-up. If the coordinator process stops, no follow-up is guaranteed; the last durable state remains for status inspection and deliberate recovery without automatic replay.</desc>
|
|
5
|
+
<defs>
|
|
6
|
+
<style>@import url('https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=Geist:wght@400;500;600&family=Geist+Mono:wght@400;500;600&display=swap');</style>
|
|
7
|
+
<marker id="worker-arrow" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto"><polygon points="0 0,8 3,0 6" fill="#4c5665"/></marker>
|
|
8
|
+
<marker id="worker-arrow-accent" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto"><polygon points="0 0,8 3,0 6" fill="#1d4ed8"/></marker>
|
|
9
|
+
</defs>
|
|
10
|
+
<rect width="1280" height="720" fill="#f0eee9"/>
|
|
11
|
+
<text x="48" y="60" fill="#6a7282" font-family="'Geist Mono',monospace" font-size="8" letter-spacing="1.5">PI SUBAGENT / ONE ISOLATED WORKER</text>
|
|
12
|
+
<text x="48" y="99" fill="#101828" font-family="'Instrument Serif',serif" font-size="28">A worker commits. Main decides what is integrated.</text>
|
|
13
|
+
<text x="48" y="126" fill="#4c5665" font-family="'Geist',sans-serif" font-size="12">The coordinator is extension code inside Main's Pi process, not another agent.</text>
|
|
14
|
+
<rect x="64" y="158" width="1138" height="58" rx="8" fill="#e6e4de"/>
|
|
15
|
+
<text x="84" y="181" fill="#101828" font-family="'Geist',sans-serif" font-size="12" font-weight="600">One worker shown; siblings can keep working after this candidate is ready.</text>
|
|
16
|
+
<text x="84" y="200" fill="#4c5665" font-family="'Geist',sans-serif" font-size="12">State is persisted by the coordinator. Herdr agent status alone does not prove a clean, checked candidate.</text>
|
|
17
|
+
<!-- Connectors before nodes. Primary rail is the checked path; dashed paths retain uncertainty. -->
|
|
18
|
+
<line x1="234" y1="350" x2="306" y2="350" stroke="#4c5665" marker-end="url(#worker-arrow)"/>
|
|
19
|
+
<line x1="476" y1="350" x2="548" y2="350" stroke="#4c5665" marker-end="url(#worker-arrow)"/>
|
|
20
|
+
<line x1="718" y1="350" x2="790" y2="350" stroke="#4c5665" marker-end="url(#worker-arrow)"/>
|
|
21
|
+
<line x1="960" y1="350" x2="1032" y2="350" stroke="#1d4ed8" stroke-width="1.4" marker-end="url(#worker-arrow-accent)"/>
|
|
22
|
+
<path d="M633 405 V434 Q633 442 625 442 H408 Q400 442 400 450 V512" fill="none" stroke="#4c5665" stroke-dasharray="5,4" marker-end="url(#worker-arrow)"/>
|
|
23
|
+
<line x1="500" y1="546" x2="526" y2="546" stroke="#4c5665" marker-end="url(#worker-arrow)"/>
|
|
24
|
+
<line x1="875" y1="405" x2="875" y2="512" stroke="#4c5665" stroke-dasharray="5,4" marker-end="url(#worker-arrow)"/>
|
|
25
|
+
<line x1="968" y1="546" x2="994" y2="546" stroke="#4c5665" marker-end="url(#worker-arrow)"/>
|
|
26
|
+
<!-- Transition labels sit above, not on, their strokes. -->
|
|
27
|
+
<g fill="#4c5665" font-family="'Geist Mono',monospace" font-size="8" text-anchor="middle">
|
|
28
|
+
<rect x="242" y="324" width="56" height="12" rx="2" fill="#f0eee9"/><text x="270" y="333">START</text>
|
|
29
|
+
<rect x="484" y="324" width="56" height="12" rx="2" fill="#f0eee9"/><text x="512" y="333">LAUNCH</text>
|
|
30
|
+
<rect x="726" y="324" width="56" height="12" rx="2" fill="#f0eee9"/><text x="754" y="333">CHECK</text>
|
|
31
|
+
<rect x="968" y="324" width="56" height="12" rx="2" fill="#f0eee9"/><text x="996" y="333" fill="#1d4ed8">READY</text>
|
|
32
|
+
</g>
|
|
33
|
+
<!-- Five phases, with ownership above each. -->
|
|
34
|
+
<g fill="#6a7282" font-family="'Geist Mono',monospace" font-size="8" letter-spacing="1" text-anchor="middle">
|
|
35
|
+
<text x="149" y="272">MAIN AGENT</text><text x="391" y="272">COORDINATOR</text>
|
|
36
|
+
<text x="633" y="272">HERDR SUBAGENT</text><text x="875" y="272">COORDINATOR</text>
|
|
37
|
+
<text x="1117" y="272">MAIN AGENT</text>
|
|
38
|
+
</g>
|
|
39
|
+
<g fill="#ffffff" stroke="#101828">
|
|
40
|
+
<rect x="64" y="295" width="170" height="110" rx="8"/>
|
|
41
|
+
<rect x="306" y="295" width="170" height="110" rx="8"/>
|
|
42
|
+
<rect x="548" y="295" width="170" height="110" rx="8"/>
|
|
43
|
+
<rect x="790" y="295" width="170" height="110" rx="8" fill="#1d4ed8" fill-opacity="0.08" stroke="#1d4ed8"/>
|
|
44
|
+
<rect x="1032" y="295" width="170" height="110" rx="8"/>
|
|
45
|
+
</g>
|
|
46
|
+
<g fill="#101828" font-family="'Geist',sans-serif" font-size="12" font-weight="600" text-anchor="middle">
|
|
47
|
+
<text x="149" y="339">Delegate task</text><text x="391" y="339">Save + launch</text>
|
|
48
|
+
<text x="633" y="339">Implement + commit</text><text x="875" y="339">Check + seal</text>
|
|
49
|
+
<text x="1117" y="339">Stage + promote</text>
|
|
50
|
+
</g>
|
|
51
|
+
<g fill="#4c5665" font-family="'Geist',sans-serif" font-size="11" text-anchor="middle">
|
|
52
|
+
<text x="149" y="370">Bounded request</text><text x="391" y="370">Durable ID + lease</text>
|
|
53
|
+
<text x="633" y="370">Isolated worktree</text><text x="875" y="370">Clean tip + task checks</text>
|
|
54
|
+
<text x="1117" y="370">Combined validation first</text>
|
|
55
|
+
</g>
|
|
56
|
+
<!-- Recovery bands: an incomplete worker and a stopped coordinator are distinct. -->
|
|
57
|
+
<rect x="286" y="468" width="448" height="146" rx="8" fill="#e6e4de"/>
|
|
58
|
+
<rect x="756" y="468" width="446" height="146" rx="8" fill="#e6e4de"/>
|
|
59
|
+
<g fill="#6a7282" font-family="'Geist Mono',monospace" font-size="8" letter-spacing="1">
|
|
60
|
+
<text x="310" y="492">WORKER FAILURE</text><text x="778" y="492">COORDINATOR STOPS / ANY PHASE</text>
|
|
61
|
+
</g>
|
|
62
|
+
<g fill="#ffffff" stroke="#4c5665" stroke-dasharray="5,4">
|
|
63
|
+
<rect x="310" y="512" width="190" height="68" rx="8"/>
|
|
64
|
+
<rect x="526" y="512" width="192" height="68" rx="8"/>
|
|
65
|
+
<rect x="778" y="512" width="190" height="68" rx="8"/>
|
|
66
|
+
<rect x="994" y="512" width="190" height="68" rx="8"/>
|
|
67
|
+
</g>
|
|
68
|
+
<g fill="#101828" font-family="'Geist',sans-serif" font-size="12" font-weight="600" text-anchor="middle">
|
|
69
|
+
<text x="405" y="540">No checked candidate</text><text x="622" y="540">Wave needs attention</text>
|
|
70
|
+
<text x="873" y="540">No guaranteed message</text><text x="1089" y="540">Inspect saved state</text>
|
|
71
|
+
</g>
|
|
72
|
+
<g fill="#4c5665" font-family="'Geist',sans-serif" font-size="10" text-anchor="middle">
|
|
73
|
+
<text x="405" y="560">Failure retained</text><text x="622" y="560">Follow-up when settled</text>
|
|
74
|
+
<text x="873" y="560">Last write survives</text><text x="1089" y="560">Restart; recover deliberately</text>
|
|
75
|
+
</g>
|
|
76
|
+
<line x1="48" y1="648" x2="1232" y2="648" stroke="#101828" stroke-opacity="0.12"/>
|
|
77
|
+
<text x="48" y="675" fill="#6a7282" font-family="'Geist Mono',monospace" font-size="8" letter-spacing="1">GUARD</text>
|
|
78
|
+
<text x="128" y="675" fill="#101828" font-family="'Geist',sans-serif" font-size="12">Status reports exact saved evidence; uncertain steps are never replayed automatically.</text>
|
|
79
|
+
<text x="128" y="696" fill="#4c5665" font-family="'Geist',sans-serif" font-size="11">A ready candidate may be staged while siblings run. Promotion still requires full combined validation.</text>
|
|
80
|
+
</svg>
|
package/extensions/isolated.ts
CHANGED
|
@@ -400,6 +400,7 @@ function toolResult(response: RunResponse, ctx: ExtensionContext, rowsByRequest:
|
|
|
400
400
|
export interface IsolatedSurface {
|
|
401
401
|
execute(params: unknown, signal: AbortSignal | undefined, ctx: ExtensionContext): Promise<ReturnType<typeof toolResult>>;
|
|
402
402
|
inventory(cwd: string): Promise<IsolatedInventory>;
|
|
403
|
+
recover(cwd: string): Promise<string>;
|
|
403
404
|
inspect(root: string, requestId: string): Promise<readonly string[]>;
|
|
404
405
|
canFollowup(root: string, requestId: string, taskId: string): boolean;
|
|
405
406
|
enqueue(root: string, requestId: string, taskId: string, text: string, current: () => boolean): string;
|
|
@@ -417,7 +418,7 @@ export interface RegisterIsolatedOptions {
|
|
|
417
418
|
export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIsolatedOptions): IsolatedSurface {
|
|
418
419
|
if (process.argv.includes(`--${ROLE_TOOL_POLICY_FLAG}`)) {
|
|
419
420
|
const denied = (): never => { throw new Error("Child Roles cannot access isolated delegation."); };
|
|
420
|
-
return { execute: async () => denied(), inventory: async () => denied(), inspect: async () => denied(), canFollowup: denied, enqueue: denied, drain: denied };
|
|
421
|
+
return { execute: async () => denied(), inventory: async () => denied(), recover: async () => denied(), inspect: async () => denied(), canFollowup: denied, enqueue: denied, drain: denied };
|
|
421
422
|
}
|
|
422
423
|
const componentsFactory = options.componentsFactory ?? createIsolatedComponents;
|
|
423
424
|
let latestCtx: ExtensionContext | undefined;
|
|
@@ -498,8 +499,11 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
|
|
|
498
499
|
const controller = new AbortController();
|
|
499
500
|
activeJobs.add(controller);
|
|
500
501
|
jobOwners.set(key, canDeliver);
|
|
502
|
+
const notified = new Set<string>();
|
|
503
|
+
let candidateListener: ((state: RunState) => void) | undefined;
|
|
501
504
|
const finish = () => {
|
|
502
505
|
activeJobs.delete(controller);
|
|
506
|
+
if (candidateListener) stateListeners.delete(candidateListener);
|
|
503
507
|
if (jobOwners.get(key) === canDeliver) jobOwners.delete(key);
|
|
504
508
|
};
|
|
505
509
|
const abortBeforeAck = () => controller.abort(signal?.reason);
|
|
@@ -514,6 +518,7 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
|
|
|
514
518
|
if (state.root !== root || state.request.id !== id) return;
|
|
515
519
|
latestState = state;
|
|
516
520
|
if (!acknowledged(state)) return;
|
|
521
|
+
for (const candidate of state.integration.candidates) notified.add(`${candidate.taskId}\0${candidate.attempt}\0${candidate.tip.head}`);
|
|
517
522
|
stateListeners.delete(listener);
|
|
518
523
|
removeTurnAbort();
|
|
519
524
|
accept(state);
|
|
@@ -539,6 +544,17 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
|
|
|
539
544
|
if (ctx.hasUI) ctx.ui.notify(`Pi Subagent ${id} result delivery failed; use subagent_status to recover.`, "error");
|
|
540
545
|
}
|
|
541
546
|
};
|
|
547
|
+
candidateListener = (state) => {
|
|
548
|
+
if (state.root !== root || state.request.id !== id || state.status !== "running" || !canDeliver()) return;
|
|
549
|
+
latestState = state;
|
|
550
|
+
for (const candidate of state.integration.candidates) {
|
|
551
|
+
const identity = `${candidate.taskId}\0${candidate.attempt}\0${candidate.tip.head}`;
|
|
552
|
+
if (candidate.decision || notified.has(identity)) continue;
|
|
553
|
+
notified.add(identity);
|
|
554
|
+
deliver(`Pi Subagent ${id}: ${candidate.taskId} ready to integrate. Inspect the exact candidate with subagent_status and stage it now; other workers may still be running.`);
|
|
555
|
+
}
|
|
556
|
+
};
|
|
557
|
+
stateListeners.add(candidateListener);
|
|
542
558
|
void Promise.resolve().then(() => start(controller.signal)).then(
|
|
543
559
|
(response) => {
|
|
544
560
|
finish();
|
|
@@ -614,6 +630,12 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
|
|
|
614
630
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
615
631
|
latestCtx = ctx;
|
|
616
632
|
const root = await lookupRoot(ctx.cwd, signal);
|
|
633
|
+
if (params.action === "advance") {
|
|
634
|
+
return await startInSession(params.id, root, signal, ctx,
|
|
635
|
+
(state) => state.status === "running" && state.waves.at(-1)?.status === "dispatching"
|
|
636
|
+
&& sameIdentity(state.waves.at(-1)!.base, params.expectedTip),
|
|
637
|
+
(runSignal) => getComponents().runner.integrate(params, root, runSignal));
|
|
638
|
+
}
|
|
617
639
|
return toolResult(await getComponents().runner.integrate(params, root, signal), ctx, workspaceRowsByRequest);
|
|
618
640
|
},
|
|
619
641
|
});
|
|
@@ -634,6 +656,47 @@ export function registerIsolatedExtension(pi: ExtensionAPI, options: RegisterIso
|
|
|
634
656
|
const root = await lookupRoot(cwd);
|
|
635
657
|
return { root, ...await getComponents().runner.listRequests(root) };
|
|
636
658
|
},
|
|
659
|
+
async recover(cwd) {
|
|
660
|
+
const root = await lookupRoot(cwd);
|
|
661
|
+
const { requests, invalidIds, leaseBusy } = await getComponents().runner.recoverRepository(root);
|
|
662
|
+
const lines = [
|
|
663
|
+
`Pi Subagent recovery in ${root}: ${leaseBusy ? "productive lease is live; no state changed" : "orphaned interruptions classified; no work replayed"}.`,
|
|
664
|
+
...(requests.length ? requests.map((response) => {
|
|
665
|
+
const state = response.state;
|
|
666
|
+
const blockers = state.tasks.flatMap((task) => [
|
|
667
|
+
...(task.failure ? [`${task.taskId}: ${boundedPublicText(task.failure)}`] : []),
|
|
668
|
+
...(task.kind === "changeset" && task.attempts.some((attempt) => attempt.prompts.some((prompt) => prompt.status === "ambiguous"))
|
|
669
|
+
? [`${task.taskId}: ambiguous worker prompt; do not replay`] : []),
|
|
670
|
+
]);
|
|
671
|
+
const generation = state.integration.generations.at(-1);
|
|
672
|
+
const readyCandidates = state.integration.candidates.filter((candidate) => candidate.worker === "retained" && !candidate.decision);
|
|
673
|
+
return [
|
|
674
|
+
`${state.request.id}: ${state.status}; Main ${response.main?.status ?? "not checked"}.`,
|
|
675
|
+
...blockers.map((blocker) => ` Blocker: ${blocker}`),
|
|
676
|
+
...(state.final.failure ? [` Final blocker: ${boundedPublicText(state.final.failure)}`] : []),
|
|
677
|
+
...(generation?.failure ? [` Integration blocker: ${boundedPublicText(generation.failure)}`] : []),
|
|
678
|
+
...(state.status !== "completed" && readyCandidates.length ? [` ${readyCandidates.length} retained candidate(s); Main selects exact candidates with subagent_stage after subagent_status.`] : []),
|
|
679
|
+
...(state.status === "completed" ? [" Retained resources after completion; inspect subagent_status for guarded cleanup."] : []),
|
|
680
|
+
...(generation ? [` Generation ${generation.number}: ${generation.status}; inspect subagent_status before any subagent_integrate action.`] : []),
|
|
681
|
+
...(!leaseBusy && response.continuation ? [` Reported next action: subagent_resume ${JSON.stringify(response.continuation)}.`] : []),
|
|
682
|
+
].join("\n");
|
|
683
|
+
}) : ["No unfinished or retained isolated requests."]),
|
|
684
|
+
...(invalidIds.length ? [`Unreadable state IDs (preserved; inspect with their compatible owner): ${invalidIds.map((id) => JSON.stringify(id)).join(", ")}.`] : []),
|
|
685
|
+
leaseBusy ? "Wait for the active productive run; use subagent_status to inspect its current state. No work was taken over."
|
|
686
|
+
: "Main: inspect subagent_status for exact identities and blockers before deciding stage, promote, resume, or guarded release. No worker prompt, merge, promotion, or cleanup was replayed.",
|
|
687
|
+
];
|
|
688
|
+
const report: string[] = [];
|
|
689
|
+
let bytes = 0;
|
|
690
|
+
let omitted = 0;
|
|
691
|
+
for (const line of lines.flatMap((entry) => entry.split("\n"))) {
|
|
692
|
+
const size = Buffer.byteLength(`${line}\n`, "utf8");
|
|
693
|
+
if (bytes + size > 7_900) { omitted++; continue; }
|
|
694
|
+
report.push(line);
|
|
695
|
+
bytes += size;
|
|
696
|
+
}
|
|
697
|
+
if (omitted) report.push(`[${omitted} recovery report lines omitted; use subagent_status for exact request evidence.]`);
|
|
698
|
+
return report.join("\n");
|
|
699
|
+
},
|
|
637
700
|
async inspect(root, requestId) {
|
|
638
701
|
const response = await getComponents().runner.status(requestId, root);
|
|
639
702
|
return inspectionNotices(response);
|
package/extensions/role-tools.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { isAbsolute } from "node:path";
|
|
|
3
3
|
import { isToolCallEventType, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
import {
|
|
5
5
|
CHILD_EXCLUDED_TOOL_NAMES,
|
|
6
|
+
EXECUTION_BUDGET_FLAG,
|
|
6
7
|
EXECUTION_BUDGET_ENV,
|
|
7
8
|
PI_SUBAGENT_PROCESS_LEASE,
|
|
8
9
|
ROLE_TOOL_POLICY_FLAG,
|
|
@@ -47,8 +48,9 @@ function configuredTools(value: unknown): string[] {
|
|
|
47
48
|
return [...new Set(parsed.map((name) => name.trim()))];
|
|
48
49
|
}
|
|
49
50
|
|
|
50
|
-
function executionBudget(value: string | undefined): EphemeralSubagentExecutionBudget | undefined {
|
|
51
|
+
function executionBudget(value: string | boolean | undefined): EphemeralSubagentExecutionBudget | undefined {
|
|
51
52
|
if (value === undefined) return;
|
|
53
|
+
if (typeof value !== "string") throw new Error(`${EXECUTION_BUDGET_ENV} must be a JSON execution budget.`);
|
|
52
54
|
let parsed: unknown;
|
|
53
55
|
try {
|
|
54
56
|
parsed = JSON.parse(value);
|
|
@@ -104,7 +106,11 @@ export default function roleTools(pi: ExtensionAPI): void {
|
|
|
104
106
|
description: "Internal Pi Subagent Role tool policy",
|
|
105
107
|
type: "string",
|
|
106
108
|
});
|
|
107
|
-
|
|
109
|
+
pi.registerFlag(EXECUTION_BUDGET_FLAG, {
|
|
110
|
+
description: "Internal Pi Subagent execution budget",
|
|
111
|
+
type: "string",
|
|
112
|
+
});
|
|
113
|
+
const budget = executionBudget(pi.getFlag(EXECUTION_BUDGET_FLAG) ?? process.env[EXECUTION_BUDGET_ENV]);
|
|
108
114
|
let handoffSent = false;
|
|
109
115
|
pi.on("session_start", () => {
|
|
110
116
|
const selected = configuredTools(pi.getFlag(ROLE_TOOL_POLICY_FLAG));
|
|
@@ -31,7 +31,8 @@ export interface IsolatedInventory {
|
|
|
31
31
|
export interface SubagentCommandAdapter {
|
|
32
32
|
direct(ctx: ExtensionContext): readonly DirectTask[];
|
|
33
33
|
isolated(cwd: string): Promise<IsolatedInventory>;
|
|
34
|
-
|
|
34
|
+
recover(cwd: string): Promise<string>;
|
|
35
|
+
inspectInTab(root: string, requestId: string, ctx: ExtensionContext, current: () => boolean): Promise<{ tabId: string; name: string; sessionFile: string }>;
|
|
35
36
|
canFollowup(root: string, requestId: string, taskId: string): boolean;
|
|
36
37
|
enqueue(root: string, requestId: string, taskId: string, text: string, current: () => boolean): string;
|
|
37
38
|
drain(root: string, requestId: string, taskId: string, current: () => boolean): readonly string[];
|
|
@@ -55,10 +56,11 @@ async function choose<T>(ctx: ExtensionContext, title: string, items: readonly P
|
|
|
55
56
|
|
|
56
57
|
export function registerSubagentCommand(pi: ExtensionAPI, adapter: SubagentCommandAdapter): void {
|
|
57
58
|
pi.registerCommand("subagent", {
|
|
58
|
-
description: "Inspect direct and isolated work
|
|
59
|
+
description: "Inspect direct and isolated work, or recover orphaned isolated requests with /subagent recover",
|
|
59
60
|
handler: async (args, ctx) => {
|
|
60
|
-
|
|
61
|
-
if (
|
|
61
|
+
const command = args.trim();
|
|
62
|
+
if (command && command !== "recover") throw new Error("Usage: /subagent [recover]");
|
|
63
|
+
if (!command && !ctx.hasUI) throw new Error("/subagent requires interactive UI (TUI or RPC); use the subagent agent tools in non-interactive mode.");
|
|
62
64
|
const manager = ctx.sessionManager;
|
|
63
65
|
const session = manager.getSessionId();
|
|
64
66
|
const file = manager.getSessionFile();
|
|
@@ -73,6 +75,13 @@ export function registerSubagentCommand(pi: ExtensionAPI, adapter: SubagentComma
|
|
|
73
75
|
return now.length >= branch.length && branch.every((id, index) => now[index]?.id === id)
|
|
74
76
|
&& now.slice(branch.length).every((entry) => !existing.has(entry.id));
|
|
75
77
|
};
|
|
78
|
+
if (command === "recover") {
|
|
79
|
+
const report = await adapter.recover(ctx.cwd);
|
|
80
|
+
if (!current()) return;
|
|
81
|
+
pi.sendMessage({ customType: "pi-subagent-recovery", content: report, display: true },
|
|
82
|
+
{ triggerTurn: true, deliverAs: "followUp" });
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
76
85
|
const valid = () => {
|
|
77
86
|
if (current()) return true;
|
|
78
87
|
ctx.ui.notify("Session or branch changed; reopen /subagent in the current context.", "warning");
|
|
@@ -130,8 +139,8 @@ export function registerSubagentCommand(pi: ExtensionAPI, adapter: SubagentComma
|
|
|
130
139
|
if (action === "back") break;
|
|
131
140
|
if (action === "inspect") {
|
|
132
141
|
try {
|
|
133
|
-
const
|
|
134
|
-
if (valid())
|
|
142
|
+
const tab = await adapter.inspectInTab(root, request.id, ctx, current);
|
|
143
|
+
if (valid()) ctx.ui.notify(`Status inspection started in Herdr tab ${tab.tabId} · agent ${tab.name} · session ${tab.sessionFile}.`, "info");
|
|
135
144
|
}
|
|
136
145
|
catch (error) { if (valid()) ctx.ui.notify(`Inspection failed: ${errorText(error)}`, "error"); }
|
|
137
146
|
continue;
|
package/extensions/subagent.ts
CHANGED
|
@@ -228,7 +228,51 @@ export default function subagentExtension(
|
|
|
228
228
|
return [...grouped.values()];
|
|
229
229
|
},
|
|
230
230
|
isolated: (cwd) => isolatedSurface.inventory(cwd),
|
|
231
|
-
|
|
231
|
+
recover: (cwd) => isolatedSurface.recover(cwd),
|
|
232
|
+
async inspectInTab(root, id, ctx, current) {
|
|
233
|
+
const notices = await isolatedSurface.inspect(root, id);
|
|
234
|
+
if (!current()) throw new Error("Session or branch changed before status inspection.");
|
|
235
|
+
const snapshot = notices.join("\n");
|
|
236
|
+
if (Buffer.byteLength(snapshot, "utf8") > 32_000) throw new Error("Status exceeds the 32 KiB inspection limit; use subagent_status for exact evidence.");
|
|
237
|
+
const role = loadRoles().find((candidate) => candidate.name === "scout");
|
|
238
|
+
if (!role || roleCanWrite(role)) throw new Error("Status inspection requires a configured read-only scout Role.");
|
|
239
|
+
const prepared = prepareRoleLaunch(pi, ctx, { role, task: DELEGATE_TASK, modelClass: "fast" });
|
|
240
|
+
const herdr = createDirectHerdr(pi, ctx.cwd, currentPolicy().childIdleMs);
|
|
241
|
+
const controller = new AbortController();
|
|
242
|
+
const taskId = `inspect-${id}-${randomUUID()}`;
|
|
243
|
+
const handles: DirectHandle[] = [];
|
|
244
|
+
const tabs: DirectTabRecord[] = [];
|
|
245
|
+
let resolveSettled!: () => void;
|
|
246
|
+
const settled = new Promise<void>((resolve) => { resolveSettled = resolve; });
|
|
247
|
+
directTasks.set(taskId, { controller, settled, handles, tabs });
|
|
248
|
+
try {
|
|
249
|
+
const transient = await materializeTransientLaunch({ launch: prepared, prompt: prepared.systemPrompt,
|
|
250
|
+
promptArgIndex: prepared.promptArgIndex }, controller.signal);
|
|
251
|
+
let handle: DirectHandle;
|
|
252
|
+
try {
|
|
253
|
+
handle = await herdr.start(
|
|
254
|
+
{ ...prepared, args: [...transient.launch.args] }, `i-${randomUUID().replaceAll("-", "").slice(0, 24)}`,
|
|
255
|
+
`Inspect ${id}`, `Analyze this saved, read-only status snapshot for request ${id}. Summarize blockers and safe next decisions; do not infer missing evidence or mutate resources. The subagent_status tool in Main remains authoritative for exact action identities.\n\n${snapshot}`,
|
|
256
|
+
controller.signal, (tab) => {
|
|
257
|
+
const record = { taskId, entryId: id, ...tab };
|
|
258
|
+
pi.appendEntry(DIRECT_TAB_TYPE, record);
|
|
259
|
+
tabs.push(record);
|
|
260
|
+
});
|
|
261
|
+
} catch (error) {
|
|
262
|
+
throw new Error(`${error instanceof Error ? error.message : String(error)}. Inspection Role prompt retained at ${transient.launch.args[prepared.promptArgIndex + 1]} after uncertain start.`, { cause: error });
|
|
263
|
+
}
|
|
264
|
+
handles.push(handle);
|
|
265
|
+
await transient.cleanup();
|
|
266
|
+
if (!current() || controller.signal.aborted) {
|
|
267
|
+
await handle.cancel();
|
|
268
|
+
throw new Error(`Session changed during inspection launch; inspect Herdr tab ${handle.tabId}.`);
|
|
269
|
+
}
|
|
270
|
+
return { tabId: handle.tabId, name: handle.name, sessionFile: handle.sessionFile };
|
|
271
|
+
} finally {
|
|
272
|
+
directTasks.delete(taskId);
|
|
273
|
+
resolveSettled();
|
|
274
|
+
}
|
|
275
|
+
},
|
|
232
276
|
canFollowup: (root, id, task) => isolatedSurface.canFollowup(root, id, task),
|
|
233
277
|
enqueue: (root, id, task, text, current) => isolatedSurface.enqueue(root, id, task, text, current),
|
|
234
278
|
drain: (root, id, task, current) => isolatedSurface.drain(root, id, task, current),
|
package/package.json
CHANGED
|
@@ -26,10 +26,10 @@ Use one compact `role`/`name`/`task` packet, `tasks` for independent packets, or
|
|
|
26
26
|
|
|
27
27
|
Give the request a unique ID and goal. Give each task a unique ID, kind, Role, model class, bounded requirements, deliverable, `dependsOn`, and `contextFrom`. Changesets require focused task checks, and a graph containing changesets requires final checks. The root must have a `package.json` test script; `pnpm test` is added to final checks if missing and runs against the combined tip before Main promotion. Text context may name only completed text tasks and preserves declared order.
|
|
28
28
|
|
|
29
|
-
Every changeset advances automatically after preliminary checks pass. While a locally owned changeset task is actively working, the user may open `/subagent` to queue bounded same-worker revisions or withdraw all still-pending instructions into the native editor and submit one replacement. Cancellation after withdrawal leaves the originals removed; already-claimed instructions cannot be recalled. Each revision must produce a new clean commit and rerun preliminary checks. When no queued revision remains, the runner seals and durably records the exact ready candidate without waiting for
|
|
29
|
+
Every changeset advances automatically after preliminary checks pass. While a locally owned changeset task is actively working, the user may open `/subagent` to queue bounded same-worker revisions or withdraw all still-pending instructions into the native editor and submit one replacement. Cancellation after withdrawal leaves the originals removed; already-claimed instructions cannot be recalled. Each revision must produce a new clean commit and rerun preliminary checks. When no queued revision remains, the runner seals and durably records the exact ready candidate without waiting for sibling workers. There is no guaranteed post-completion editing window, and late follow-ups fail visibly.
|
|
30
30
|
|
|
31
|
-
A ready candidate does not integrate automatically.
|
|
31
|
+
A ready candidate does not integrate automatically. Each checked worker triggers a follow-up while its siblings continue; inspect `subagent_status` and immediately use `subagent_stage` with the exact `generation`, `taskId`, `attempt`, candidate `tip`, and `expectedTip` to select and merge it into the owned integration checkout. Stage and resolve are available during the active wave; wait for the wave to settle before rejecting or revising. Resolve conflicts in the integration checkout and verify with `resolve`; never edit Main to resolve them. For dependents, `subagent_integrate advance` acknowledges a durable background wave, then reports checked candidates as they finish. When selected work is staged and the wave settles, use `subagent_integrate validate` on the exact `combinedTip` for full root checks and optional final judgment; `promote` requires passing evidence and unchanged Main. One Main-authored committed correction to a definitively failed combined validation can be recorded with `correct`, then revalidated. Only proven promotion triggers selected-worker termination and verified cleanup. Failures, ambiguity, conflicts, interruption, child-limit exhaustion, or Main drift retain work and never waive checks or identity guards. The extension never pushes, opens a pull request, publishes, or deploys.
|
|
32
32
|
|
|
33
|
-
Use `subagent_status` to inspect durable state without mutation, including candidate identities, generations, and retained resources. `subagent_resume` handles only the reported retry/verify/finalize continuation; it cannot replace Main's stage/integrate decisions or reset policy/corrections. `subagent_stage reject` or `revise` freezes a generation containing the candidate and invalidates dependent attempts; restage chosen work in a new generation. If Main advances cleanly before promotion, use `subagent_integrate refresh` with exact old/new Main and combined-tip identities; restage immutable candidates into the new generation and rerun combined validation. Dirty or divergent Main blocks refresh. At most two unreleased integration worktrees may coexist. Rejected candidates and superseded generations remain retained until Main explicitly calls `subagent_integrate release` with the exact generation and tip; for a rejected worker include its task ID and attempt after releasing all generations using it. Dirty/conflicted or unproved resources block release without force deletion. Impossible fresh dependent work after rejection needs a new request. `subagent_abort` cannot discard retained candidates or integration generations; it may finish once all are released. Old v4 state is rejected (current state is v5), not migrated. Status, abort, process I/O, termination, and verified post-promotion cleanup keep finite safety budgets.
|
|
33
|
+
After a coordinator interruption in the canonical repository, ask the user to run `/subagent recover` once: it acquires the productive lease, classifies orphaned interruptions, preserves invalid files and retained work, and sends Main one follow-up with blockers and next actions. A live productive lease is read-only and cannot be taken over. Recovery never replays prompts, merges, promotion, or cleanup. Use `subagent_status` to inspect exact durable state without mutation, including candidate identities, generations, and retained resources. `subagent_resume` handles only the reported retry/verify/finalize continuation; it cannot replace Main's stage/integrate decisions or reset policy/corrections. `subagent_stage reject` or `revise` freezes a generation containing the candidate and invalidates dependent attempts; restage chosen work in a new generation. If Main advances cleanly before promotion, use `subagent_integrate refresh` with exact old/new Main and combined-tip identities; restage immutable candidates into the new generation and rerun combined validation. Dirty or divergent Main blocks refresh. At most two unreleased integration worktrees may coexist. Rejected candidates and superseded generations remain retained until Main explicitly calls `subagent_integrate release` with the exact generation and tip; for a rejected worker include its task ID and attempt after releasing all generations using it. Dirty/conflicted or unproved resources block release without force deletion. Impossible fresh dependent work after rejection needs a new request. `subagent_abort` cannot discard retained candidates or integration generations; it may finish once all are released. Old v4 state is rejected (current state is v5), not migrated. Status, abort, process I/O, termination, and verified post-promotion cleanup keep finite safety budgets.
|
|
34
34
|
|
|
35
35
|
All launches use the global policy in `config/pi-subagent/config.json`: productive concurrency, per-child turns/tokens, and idle timeout. Ephemeral children also have a hard runtime limit; isolated requests have a correction cap. Productive requests have no whole-run wall-clock deadline. Request fields cannot replenish or override limits.
|
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
<!doctype html>
|
|
2
|
-
<html lang="en">
|
|
3
|
-
<head>
|
|
4
|
-
<meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
5
|
-
<title>Delegation routing and checked task lifecycle</title>
|
|
6
|
-
<link href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=Geist:wght@400;500;600&family=Geist+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
|
7
|
-
<style>
|
|
8
|
-
* { box-sizing: border-box; } body { margin: 0; padding: 32px; background: #f0eee9; color: #101828; font-family: 'Geist', sans-serif; }
|
|
9
|
-
main { max-width: 1280px; margin: auto; } h1 { font: 400 28px 'Instrument Serif', serif; margin: 0 0 16px; }
|
|
10
|
-
svg { display: block; width: 100%; height: auto; }
|
|
11
|
-
</style>
|
|
12
|
-
</head>
|
|
13
|
-
<body><main><h1>Delegation routing and checked task lifecycle</h1>
|
|
14
|
-
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720" role="img" aria-labelledby="delegation-routing-title delegation-routing-desc">
|
|
15
|
-
<title id="delegation-routing-title">Delegation routing and checked task lifecycle</title>
|
|
16
|
-
<desc id="delegation-routing-desc">A read-only Role runs direct in the current workspace; implementation uses an isolated checked graph. In that graph workers produce a checked candidate, Main stages and validates the combined tip, then explicitly promotes it. Interruptions retain work for recovery and revalidation.</desc>
|
|
17
|
-
<defs>
|
|
18
|
-
<marker id="routing-arrow" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto"><polygon points="0 0,8 3,0 6" fill="#4c5665"/></marker>
|
|
19
|
-
<marker id="routing-arrow-accent" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto"><polygon points="0 0,8 3,0 6" fill="#1d4ed8"/></marker>
|
|
20
|
-
<marker id="routing-arrow-link" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto"><polygon points="0 0,8 3,0 6" fill="#4c5665"/></marker>
|
|
21
|
-
</defs>
|
|
22
|
-
<rect width="1280" height="720" fill="#f0eee9"/>
|
|
23
|
-
<text x="48" y="60" fill="#6a7282" font-family="'Geist Mono',monospace" font-size="8" letter-spacing="1.5">PI SUBAGENT / ROUTING + LIFECYCLE</text>
|
|
24
|
-
<text x="48" y="99" fill="#101828" font-family="'Instrument Serif',serif" font-size="28">One explicit mode; only checked work can be promoted</text>
|
|
25
|
-
<text x="48" y="126" fill="#4c5665" font-family="'Geist',sans-serif" font-size="12">Direct work returns an observed result. Isolated changesets yield a candidate that Main must validate and promote.</text>
|
|
26
|
-
<!-- Routes and transitions are drawn before nodes. -->
|
|
27
|
-
<line x1="224" y1="212" x2="248" y2="212" stroke="#4c5665" marker-end="url(#routing-arrow)"/>
|
|
28
|
-
<path d="M336 268 V296 Q336 304 328 304 H192 Q184 304 184 312 V352" fill="none" stroke="#4c5665" marker-end="url(#routing-arrow)"/>
|
|
29
|
-
<path d="M424 212 H488 Q496 212 496 220 V352" fill="none" stroke="#1d4ed8" stroke-width="1.4" marker-end="url(#routing-arrow-accent)"/>
|
|
30
|
-
<line x1="560" y1="400" x2="624" y2="400" stroke="#4c5665" marker-end="url(#routing-arrow)"/>
|
|
31
|
-
<line x1="784" y1="400" x2="848" y2="400" stroke="#4c5665" marker-end="url(#routing-arrow)"/>
|
|
32
|
-
<line x1="1008" y1="400" x2="1072" y2="400" stroke="#1d4ed8" stroke-width="1.4" marker-end="url(#routing-arrow-accent)"/>
|
|
33
|
-
<!-- Interruption / re-entry paths use separate attach points. -->
|
|
34
|
-
<line x1="448" y1="448" x2="448" y2="512" stroke="#4c5665" stroke-dasharray="5,4" marker-end="url(#routing-arrow)"/>
|
|
35
|
-
<line x1="512" y1="512" x2="512" y2="448" stroke="#4c5665" stroke-dasharray="5,4" marker-end="url(#routing-arrow)"/>
|
|
36
|
-
<line x1="896" y1="448" x2="896" y2="512" stroke="#4c5665" stroke-dasharray="5,4" marker-end="url(#routing-arrow)"/>
|
|
37
|
-
<line x1="960" y1="512" x2="960" y2="448" stroke="#4c5665" stroke-dasharray="5,4" marker-end="url(#routing-arrow)"/>
|
|
38
|
-
<!-- Arrow labels have an 8px gap to their strokes. -->
|
|
39
|
-
<g fill="#4c5665" font-family="'Geist Mono',monospace" font-size="8" text-anchor="middle">
|
|
40
|
-
<rect x="256" y="284" width="48" height="12" rx="2" fill="#f0eee9"/><text x="280" y="293">YES</text>
|
|
41
|
-
<rect x="432" y="192" width="72" height="12" rx="2" fill="#f0eee9"/><text x="468" y="201" fill="#1d4ed8">NO / GRAPH</text>
|
|
42
|
-
<rect x="568" y="380" width="48" height="12" rx="2" fill="#f0eee9"/><text x="592" y="389">CHECK</text>
|
|
43
|
-
<rect x="792" y="380" width="48" height="12" rx="2" fill="#f0eee9"/><text x="816" y="389">STAGE</text>
|
|
44
|
-
<rect x="1016" y="380" width="48" height="12" rx="2" fill="#f0eee9"/><text x="1040" y="389" fill="#1d4ed8">VERIFY</text>
|
|
45
|
-
<rect x="384" y="472" width="56" height="12" rx="2" fill="#f0eee9"/><text x="412" y="481">PAUSE</text>
|
|
46
|
-
<rect x="520" y="472" width="56" height="12" rx="2" fill="#f0eee9"/><text x="548" y="481">RESUME</text>
|
|
47
|
-
<rect x="816" y="472" width="72" height="12" rx="2" fill="#f0eee9"/><text x="852" y="481">MAIN DRIFT</text>
|
|
48
|
-
<rect x="968" y="472" width="72" height="12" rx="2" fill="#f0eee9"/><text x="1004" y="481">RECHECK</text>
|
|
49
|
-
</g>
|
|
50
|
-
<!-- Mode selection. -->
|
|
51
|
-
<rect x="80" y="180" width="144" height="64" rx="24" fill="#ffffff" stroke="#101828"/>
|
|
52
|
-
<text x="152" y="217" text-anchor="middle" fill="#101828" font-family="'Geist',sans-serif" font-size="12" font-weight="600">Delegate task</text>
|
|
53
|
-
<polygon points="336,156 424,212 336,268 248,212" fill="#ffffff" stroke="#101828"/>
|
|
54
|
-
<text x="336" y="208" text-anchor="middle" fill="#101828" font-family="'Geist',sans-serif" font-size="12" font-weight="600">Direct</text>
|
|
55
|
-
<text x="336" y="226" text-anchor="middle" fill="#101828" font-family="'Geist',sans-serif" font-size="12" font-weight="600">eligible?</text>
|
|
56
|
-
<!-- Direct is intentionally a single terminal branch: no worktree or candidate. -->
|
|
57
|
-
<text x="80" y="336" fill="#6a7282" font-family="'Geist Mono',monospace" font-size="8" letter-spacing="1">DIRECT</text>
|
|
58
|
-
<rect x="80" y="352" width="208" height="96" rx="8" fill="#ffffff" stroke="#101828"/>
|
|
59
|
-
<text x="184" y="384" text-anchor="middle" fill="#101828" font-family="'Geist',sans-serif" font-size="12" font-weight="600">Herdr tabs</text>
|
|
60
|
-
<text x="184" y="406" text-anchor="middle" fill="#4c5665" font-family="'Geist',sans-serif" font-size="12">Current workspace</text>
|
|
61
|
-
<text x="184" y="428" text-anchor="middle" fill="#4c5665" font-family="'Geist',sans-serif" font-size="12">Observe result</text>
|
|
62
|
-
<text x="80" y="477" fill="#4c5665" font-family="'Geist',sans-serif" font-size="12">Single · parallel · chain</text>
|
|
63
|
-
<!-- Isolated changeset rail. -->
|
|
64
|
-
<text x="400" y="336" fill="#6a7282" font-family="'Geist Mono',monospace" font-size="8" letter-spacing="1">ISOLATED / CHECKED CHANGESET</text>
|
|
65
|
-
<rect x="400" y="352" width="160" height="96" rx="8" fill="#ffffff" stroke="#101828"/>
|
|
66
|
-
<text x="480" y="387" text-anchor="middle" fill="#101828" font-family="'Geist',sans-serif" font-size="12" font-weight="600">Saved + working</text>
|
|
67
|
-
<text x="480" y="411" text-anchor="middle" fill="#4c5665" font-family="'Geist',sans-serif" font-size="12">Durable ID</text>
|
|
68
|
-
<text x="480" y="429" text-anchor="middle" fill="#4c5665" font-family="'Geist',sans-serif" font-size="12">Owned worktree</text>
|
|
69
|
-
<rect x="624" y="352" width="160" height="96" rx="8" fill="#ffffff" stroke="#101828"/>
|
|
70
|
-
<text x="704" y="390" text-anchor="middle" fill="#101828" font-family="'Geist',sans-serif" font-size="12" font-weight="600">Ready candidate</text>
|
|
71
|
-
<text x="704" y="416" text-anchor="middle" fill="#4c5665" font-family="'Geist',sans-serif" font-size="12">Clean commit + checks</text>
|
|
72
|
-
<rect x="848" y="352" width="160" height="96" rx="8" fill="#ffffff" stroke="#101828"/>
|
|
73
|
-
<text x="928" y="390" text-anchor="middle" fill="#101828" font-family="'Geist',sans-serif" font-size="12" font-weight="600">Staged by Main</text>
|
|
74
|
-
<text x="928" y="416" text-anchor="middle" fill="#4c5665" font-family="'Geist',sans-serif" font-size="12">Exact candidate tip</text>
|
|
75
|
-
<rect x="1072" y="352" width="160" height="96" rx="8" fill="#1d4ed8" fill-opacity="0.08" stroke="#1d4ed8"/>
|
|
76
|
-
<text x="1152" y="390" text-anchor="middle" fill="#101828" font-family="'Geist',sans-serif" font-size="12" font-weight="600">Promoted by Main</text>
|
|
77
|
-
<text x="1152" y="416" text-anchor="middle" fill="#4c5665" font-family="'Geist',sans-serif" font-size="12">After combined checks</text>
|
|
78
|
-
<!-- Recovery states do not erase or bypass exact checks. -->
|
|
79
|
-
<text x="400" y="497" fill="#6a7282" font-family="'Geist Mono',monospace" font-size="8" letter-spacing="1">WAIT / RECOVERY</text>
|
|
80
|
-
<rect x="400" y="512" width="160" height="72" rx="8" fill="#e6e4de" stroke="#4c5665" stroke-dasharray="5,4"/>
|
|
81
|
-
<text x="480" y="541" text-anchor="middle" fill="#101828" font-family="'Geist',sans-serif" font-size="12" font-weight="600">Needs attention</text>
|
|
82
|
-
<text x="480" y="562" text-anchor="middle" fill="#4c5665" font-family="'Geist',sans-serif" font-size="12">Status / retry</text>
|
|
83
|
-
<rect x="848" y="512" width="160" height="72" rx="8" fill="#e6e4de" stroke="#4c5665" stroke-dasharray="5,4"/>
|
|
84
|
-
<text x="928" y="541" text-anchor="middle" fill="#101828" font-family="'Geist',sans-serif" font-size="12" font-weight="600">Revalidate</text>
|
|
85
|
-
<text x="928" y="562" text-anchor="middle" fill="#4c5665" font-family="'Geist',sans-serif" font-size="12">Restage if needed</text>
|
|
86
|
-
<line x1="48" y1="648" x2="1232" y2="648" stroke="#101828" stroke-opacity="0.12"/>
|
|
87
|
-
<text x="48" y="674" fill="#4c5665" font-family="'Geist Mono',monospace" font-size="8" letter-spacing="1">GUARD</text>
|
|
88
|
-
<text x="128" y="674" fill="#101828" font-family="'Geist',sans-serif" font-size="12">No fallback between modes. Failed validation retains evidence; abort requires no retained work.</text>
|
|
89
|
-
</svg></main></body></html>
|
|
@@ -1,78 +0,0 @@
|
|
|
1
|
-
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
-
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="720" viewBox="0 0 1280 720" role="img" aria-labelledby="delegation-routing-title delegation-routing-desc">
|
|
3
|
-
<title id="delegation-routing-title">Delegation routing and checked task lifecycle</title>
|
|
4
|
-
<desc id="delegation-routing-desc">A read-only Role runs direct in the current workspace; implementation uses an isolated checked graph. In that graph workers produce a checked candidate, Main stages and validates the combined tip, then explicitly promotes it. Interruptions retain work for recovery and revalidation.</desc>
|
|
5
|
-
<defs>
|
|
6
|
-
<style>@import url('https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=Geist:wght@400;500;600&family=Geist+Mono:wght@400;500;600&display=swap');</style>
|
|
7
|
-
<marker id="routing-arrow" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto"><polygon points="0 0,8 3,0 6" fill="#4c5665"/></marker>
|
|
8
|
-
<marker id="routing-arrow-accent" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto"><polygon points="0 0,8 3,0 6" fill="#1d4ed8"/></marker>
|
|
9
|
-
<marker id="routing-arrow-link" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto"><polygon points="0 0,8 3,0 6" fill="#4c5665"/></marker>
|
|
10
|
-
</defs>
|
|
11
|
-
<rect width="1280" height="720" fill="#f0eee9"/>
|
|
12
|
-
<text x="48" y="60" fill="#6a7282" font-family="'Geist Mono',monospace" font-size="8" letter-spacing="1.5">PI SUBAGENT / ROUTING + LIFECYCLE</text>
|
|
13
|
-
<text x="48" y="99" fill="#101828" font-family="'Instrument Serif',serif" font-size="28">One explicit mode; only checked work can be promoted</text>
|
|
14
|
-
<text x="48" y="126" fill="#4c5665" font-family="'Geist',sans-serif" font-size="12">Direct work returns an observed result. Isolated changesets yield a candidate that Main must validate and promote.</text>
|
|
15
|
-
<!-- Routes and transitions are drawn before nodes. -->
|
|
16
|
-
<line x1="224" y1="212" x2="248" y2="212" stroke="#4c5665" marker-end="url(#routing-arrow)"/>
|
|
17
|
-
<path d="M336 268 V296 Q336 304 328 304 H192 Q184 304 184 312 V352" fill="none" stroke="#4c5665" marker-end="url(#routing-arrow)"/>
|
|
18
|
-
<path d="M424 212 H488 Q496 212 496 220 V352" fill="none" stroke="#1d4ed8" stroke-width="1.4" marker-end="url(#routing-arrow-accent)"/>
|
|
19
|
-
<line x1="560" y1="400" x2="624" y2="400" stroke="#4c5665" marker-end="url(#routing-arrow)"/>
|
|
20
|
-
<line x1="784" y1="400" x2="848" y2="400" stroke="#4c5665" marker-end="url(#routing-arrow)"/>
|
|
21
|
-
<line x1="1008" y1="400" x2="1072" y2="400" stroke="#1d4ed8" stroke-width="1.4" marker-end="url(#routing-arrow-accent)"/>
|
|
22
|
-
<!-- Interruption / re-entry paths use separate attach points. -->
|
|
23
|
-
<line x1="448" y1="448" x2="448" y2="512" stroke="#4c5665" stroke-dasharray="5,4" marker-end="url(#routing-arrow)"/>
|
|
24
|
-
<line x1="512" y1="512" x2="512" y2="448" stroke="#4c5665" stroke-dasharray="5,4" marker-end="url(#routing-arrow)"/>
|
|
25
|
-
<line x1="896" y1="448" x2="896" y2="512" stroke="#4c5665" stroke-dasharray="5,4" marker-end="url(#routing-arrow)"/>
|
|
26
|
-
<line x1="960" y1="512" x2="960" y2="448" stroke="#4c5665" stroke-dasharray="5,4" marker-end="url(#routing-arrow)"/>
|
|
27
|
-
<!-- Arrow labels have an 8px gap to their strokes. -->
|
|
28
|
-
<g fill="#4c5665" font-family="'Geist Mono',monospace" font-size="8" text-anchor="middle">
|
|
29
|
-
<rect x="256" y="284" width="48" height="12" rx="2" fill="#f0eee9"/><text x="280" y="293">YES</text>
|
|
30
|
-
<rect x="432" y="192" width="72" height="12" rx="2" fill="#f0eee9"/><text x="468" y="201" fill="#1d4ed8">NO / GRAPH</text>
|
|
31
|
-
<rect x="568" y="380" width="48" height="12" rx="2" fill="#f0eee9"/><text x="592" y="389">CHECK</text>
|
|
32
|
-
<rect x="792" y="380" width="48" height="12" rx="2" fill="#f0eee9"/><text x="816" y="389">STAGE</text>
|
|
33
|
-
<rect x="1016" y="380" width="48" height="12" rx="2" fill="#f0eee9"/><text x="1040" y="389" fill="#1d4ed8">VERIFY</text>
|
|
34
|
-
<rect x="384" y="472" width="56" height="12" rx="2" fill="#f0eee9"/><text x="412" y="481">PAUSE</text>
|
|
35
|
-
<rect x="520" y="472" width="56" height="12" rx="2" fill="#f0eee9"/><text x="548" y="481">RESUME</text>
|
|
36
|
-
<rect x="816" y="472" width="72" height="12" rx="2" fill="#f0eee9"/><text x="852" y="481">MAIN DRIFT</text>
|
|
37
|
-
<rect x="968" y="472" width="72" height="12" rx="2" fill="#f0eee9"/><text x="1004" y="481">RECHECK</text>
|
|
38
|
-
</g>
|
|
39
|
-
<!-- Mode selection. -->
|
|
40
|
-
<rect x="80" y="180" width="144" height="64" rx="24" fill="#ffffff" stroke="#101828"/>
|
|
41
|
-
<text x="152" y="217" text-anchor="middle" fill="#101828" font-family="'Geist',sans-serif" font-size="12" font-weight="600">Delegate task</text>
|
|
42
|
-
<polygon points="336,156 424,212 336,268 248,212" fill="#ffffff" stroke="#101828"/>
|
|
43
|
-
<text x="336" y="208" text-anchor="middle" fill="#101828" font-family="'Geist',sans-serif" font-size="12" font-weight="600">Direct</text>
|
|
44
|
-
<text x="336" y="226" text-anchor="middle" fill="#101828" font-family="'Geist',sans-serif" font-size="12" font-weight="600">eligible?</text>
|
|
45
|
-
<!-- Direct is intentionally a single terminal branch: no worktree or candidate. -->
|
|
46
|
-
<text x="80" y="336" fill="#6a7282" font-family="'Geist Mono',monospace" font-size="8" letter-spacing="1">DIRECT</text>
|
|
47
|
-
<rect x="80" y="352" width="208" height="96" rx="8" fill="#ffffff" stroke="#101828"/>
|
|
48
|
-
<text x="184" y="384" text-anchor="middle" fill="#101828" font-family="'Geist',sans-serif" font-size="12" font-weight="600">Herdr tabs</text>
|
|
49
|
-
<text x="184" y="406" text-anchor="middle" fill="#4c5665" font-family="'Geist',sans-serif" font-size="12">Current workspace</text>
|
|
50
|
-
<text x="184" y="428" text-anchor="middle" fill="#4c5665" font-family="'Geist',sans-serif" font-size="12">Observe result</text>
|
|
51
|
-
<text x="80" y="477" fill="#4c5665" font-family="'Geist',sans-serif" font-size="12">Single · parallel · chain</text>
|
|
52
|
-
<!-- Isolated changeset rail. -->
|
|
53
|
-
<text x="400" y="336" fill="#6a7282" font-family="'Geist Mono',monospace" font-size="8" letter-spacing="1">ISOLATED / CHECKED CHANGESET</text>
|
|
54
|
-
<rect x="400" y="352" width="160" height="96" rx="8" fill="#ffffff" stroke="#101828"/>
|
|
55
|
-
<text x="480" y="387" text-anchor="middle" fill="#101828" font-family="'Geist',sans-serif" font-size="12" font-weight="600">Saved + working</text>
|
|
56
|
-
<text x="480" y="411" text-anchor="middle" fill="#4c5665" font-family="'Geist',sans-serif" font-size="12">Durable ID</text>
|
|
57
|
-
<text x="480" y="429" text-anchor="middle" fill="#4c5665" font-family="'Geist',sans-serif" font-size="12">Owned worktree</text>
|
|
58
|
-
<rect x="624" y="352" width="160" height="96" rx="8" fill="#ffffff" stroke="#101828"/>
|
|
59
|
-
<text x="704" y="390" text-anchor="middle" fill="#101828" font-family="'Geist',sans-serif" font-size="12" font-weight="600">Ready candidate</text>
|
|
60
|
-
<text x="704" y="416" text-anchor="middle" fill="#4c5665" font-family="'Geist',sans-serif" font-size="12">Clean commit + checks</text>
|
|
61
|
-
<rect x="848" y="352" width="160" height="96" rx="8" fill="#ffffff" stroke="#101828"/>
|
|
62
|
-
<text x="928" y="390" text-anchor="middle" fill="#101828" font-family="'Geist',sans-serif" font-size="12" font-weight="600">Staged by Main</text>
|
|
63
|
-
<text x="928" y="416" text-anchor="middle" fill="#4c5665" font-family="'Geist',sans-serif" font-size="12">Exact candidate tip</text>
|
|
64
|
-
<rect x="1072" y="352" width="160" height="96" rx="8" fill="#1d4ed8" fill-opacity="0.08" stroke="#1d4ed8"/>
|
|
65
|
-
<text x="1152" y="390" text-anchor="middle" fill="#101828" font-family="'Geist',sans-serif" font-size="12" font-weight="600">Promoted by Main</text>
|
|
66
|
-
<text x="1152" y="416" text-anchor="middle" fill="#4c5665" font-family="'Geist',sans-serif" font-size="12">After combined checks</text>
|
|
67
|
-
<!-- Recovery states do not erase or bypass exact checks. -->
|
|
68
|
-
<text x="400" y="497" fill="#6a7282" font-family="'Geist Mono',monospace" font-size="8" letter-spacing="1">WAIT / RECOVERY</text>
|
|
69
|
-
<rect x="400" y="512" width="160" height="72" rx="8" fill="#e6e4de" stroke="#4c5665" stroke-dasharray="5,4"/>
|
|
70
|
-
<text x="480" y="541" text-anchor="middle" fill="#101828" font-family="'Geist',sans-serif" font-size="12" font-weight="600">Needs attention</text>
|
|
71
|
-
<text x="480" y="562" text-anchor="middle" fill="#4c5665" font-family="'Geist',sans-serif" font-size="12">Status / retry</text>
|
|
72
|
-
<rect x="848" y="512" width="160" height="72" rx="8" fill="#e6e4de" stroke="#4c5665" stroke-dasharray="5,4"/>
|
|
73
|
-
<text x="928" y="541" text-anchor="middle" fill="#101828" font-family="'Geist',sans-serif" font-size="12" font-weight="600">Revalidate</text>
|
|
74
|
-
<text x="928" y="562" text-anchor="middle" fill="#4c5665" font-family="'Geist',sans-serif" font-size="12">Restage if needed</text>
|
|
75
|
-
<line x1="48" y1="648" x2="1232" y2="648" stroke="#101828" stroke-opacity="0.12"/>
|
|
76
|
-
<text x="48" y="674" fill="#4c5665" font-family="'Geist Mono',monospace" font-size="8" letter-spacing="1">GUARD</text>
|
|
77
|
-
<text x="128" y="674" fill="#101828" font-family="'Geist',sans-serif" font-size="12">No fallback between modes. Failed validation retains evidence; abort requires no retained work.</text>
|
|
78
|
-
</svg>
|